diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db2161c..eec0a4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,41 @@ - All external API calls must be mockable via `httptest` - Run `make lint` before submitting +### Command patterns (legacy vs. new) + +There are intentionally **two** Cobra command patterns in this repo: + +- **Legacy (accounting commands):** package-global flag variables (`format`, + `fields`, `debug`), `Run` handlers that call `os.Exit` via `exitError`, + and `outputResult` writing directly to `os.Stdout`. Existing `cmd/journals.go` + and friends follow this pattern. +- **New (invoice commands):** dependency injection via `cmdDeps`, pure + `runFoo(deps, opts)` runner functions returning `error`, options structs + built from a `parseFooOptions(cmd, args)` helper, and writes through + injected `io.Writer` values (`outputResultTo`). Cobra `RunE` is a thin + wrapper that calls the pure runner and routes errors through + `exitInvoiceAPIError` / `exitError`. + +New commands should follow the new pattern. Existing commands stay as-is +unless a separate refactor PR converts them. + +### Invoice model imports + +When importing the invoice model package, alias it as `invoicemodel` so +the call site reads consistently across files: + +```go +import invoicemodel "github.com/beatinaniwa/mf-cli/internal/model/invoice" +``` + +### Test fixtures + +Invoice testdata under `internal/api/testdata/` is pinned to the +committed `iv_openapi.yaml`. When you regenerate fixtures, update +`internal/api/testdata/README.md` so the provenance manifest stays in +sync. Fixture filenames using the `*_minimal.json` suffix indicate +schema-required minimums rather than full spec examples. + ## Testing Policy - Run: `make test` (includes `-race` flag) diff --git a/README.md b/README.md index 8104b94..39160c8 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,19 @@ -# mf - MoneyForward会計 CLI +# mf - MoneyForward 会計 / 請求書 CLI [![Go](https://img.shields.io/github/go-mod/go-version/beatinaniwa/mf-cli)](https://go.dev/) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![CI](https://github.com/beatinaniwa/mf-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/beatinaniwa/mf-cli/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/beatinaniwa/mf-cli)](https://github.com/beatinaniwa/mf-cli/releases) -[マネーフォワード クラウド会計](https://biz.moneyforward.com/accounting)のAPIを操作するコマンドラインツールです。 +[マネーフォワード クラウド会計](https://biz.moneyforward.com/accounting) と [マネーフォワード クラウド請求書](https://biz.moneyforward.com/invoice) のAPIを操作するコマンドラインツールです。 > **Note:** リポジトリ名は `mf-cli` ですが、インストールされるバイナリ名は `mf` です。 ## 特徴 - OAuth 2.0 + PKCE による認証 -- 仕訳帳・勘定科目・取引先・部門・税区分などのCRUD操作 +- 仕訳帳・勘定科目・取引先・部門・税区分などのCRUD操作(クラウド会計) +- 請求書の作成・更新・削除と取引先 / 部署 / 品目の参照(クラウド請求書) - 試算表・推移表などの財務レポート取得 - JSON / テーブル形式での出力切替 - `--dry-run` による安全な事前確認 @@ -69,7 +70,7 @@ export MF_CLIENT_SECRET=your_client_secret # 公開クライアントの場合 # ブラウザで認証(デフォルト) mf auth login -# 書き込み権限も含める場合 +# 書き込み権限も含める場合(クラウド請求書APIを使う場合は必須) mf auth login --scopes all # ブラウザを使わずに認証(SSHセッション等) @@ -97,9 +98,24 @@ mf journals create --json '{"..."}' --dry-run # 試算表(P/L)を取得 mf reports trial-balance-pl --fiscal-year 2024 -# APIスキーマを確認 +# APIスキーマを確認(会計) mf describe --list # 利用可能なリソース一覧 mf describe journals # journals の詳細 + +# 請求書APIスキーマを確認 +mf describe --api invoice --list +mf describe --api invoice invoices + +# 請求書ドラフトを作成(dry-run で事前確認) +mf invoices create --dry-run --json '{"department_id":"...","billing_date":"2026-05-08","title":"請求書","items":[{"name":"作業費","price":10000,"quantity":1,"excise":"ten_percent"}]}' + +# 請求書一覧 +mf invoices list --per-page 5 + +# 取引先・部署・品目の参照 +mf invoice-partners list +mf invoice-departments list --partner-id +mf invoice-items list ``` ## コマンド一覧 @@ -129,7 +145,15 @@ mf describe journals # journals の詳細 | `reports trial-balance-pl` | 試算表(P/L) | o | | | | `reports transition-bs` | 推移表(B/S) | | | | | `reports transition-pl` | 推移表(P/L) | | | | -| `describe [resource]` | APIスキーマ表示 | o | | | +| `describe [resource]` | APIスキーマ表示(`--api accounting\|invoice` で切替) | o | | | +| `invoices list` | 請求書一覧 | o | | | +| `invoices get ` | 請求書詳細 | | | | +| `invoices create` | 請求書ドラフト作成(POST `/invoice_template_billings`) | | o | o | +| `invoices update ` | 請求書更新 | | o | o | +| `invoices delete ` | 請求書削除 | | o | | +| `invoice-partners list` / `get` | 請求書取引先 | o | | | +| `invoice-departments list` / `get` | 取引先の部署(`--partner-id` 必須) | o | | | +| `invoice-items list` / `get` | 請求書品目 | o | | | | `version` | バージョン表示 | | | | ## 入出力 @@ -184,6 +208,7 @@ mf journals create --json '{"..."}' --dry-run | `MF_AUTH_PORT` | ローカルコールバックポート | `8089` | | `MF_CONFIG_DIR` | 設定ディレクトリのパス | OS依存(下記参照) | | `MF_SCOPES` | スペース区切りのスコープリスト | `--scopes` フラグより優先 | +| `MF_INVOICE_BASE_URL` | クラウド請求書 API のオリジン(`/api/v3` を含めない) | `https://invoice.moneyforward.com` | 環境変数は設定ファイルの値を上書きします。 @@ -205,10 +230,13 @@ mf journals create --json '{"..."}' --dry-run "client_id": "your_client_id", "client_secret": "your_client_secret", "base_url": "https://api-accounting.moneyforward.com", + "invoice_base_url": "https://invoice.moneyforward.com", "auth_port": 8089 } ``` +> `invoice_base_url` は origin のみを指定してください(末尾の `/api/v3` は不要)。`/api/v3` 付きで設定された場合や末尾スラッシュは自動でトリムされ、それ以外の不正値は invoice 系コマンド実行時にエラーになります。 + #### token.json 認証トークンは同ディレクトリ内の `token.json` に自動保存されます。 @@ -222,7 +250,8 @@ mf-cli はAIエージェント(Claude Code、Codex等)からの利用に適 - `--fields` でJSON出力から必要なフィールドのみ抽出可能 - `--dry-run` で安全にリクエスト内容を事前確認 - `--json -` で標準入力からJSONを渡せる -- エラーは構造化JSONとしてstderrに出力 +- エラーは構造化JSONとしてstderrに出力(cobra/pflagの未定義フラグや型不正のみ平文で stderr に出力されます) +- 請求書APIを使うには `mf auth login --scopes all` で再認可が必要です(`MF_SCOPES` を設定中の場合は `unset MF_SCOPES` してから実行してください) ## 開発 diff --git a/cmd/describe.go b/cmd/describe.go index bc1b66c..f392d62 100644 --- a/cmd/describe.go +++ b/cmd/describe.go @@ -3,6 +3,7 @@ package cmd import ( "encoding/json" "fmt" + "io" "os" "strings" @@ -11,84 +12,162 @@ import ( "github.com/spf13/cobra" ) -// EmbeddedSpec holds the OpenAPI YAML bytes, set by main before Execute(). +// EmbeddedSpec is preserved for backwards-compatibility with callers that +// still set a single accounting spec. New code should populate EmbeddedSpecs +// instead. var EmbeddedSpec []byte +// EmbeddedSpecs holds the OpenAPI YAML bytes per API name, set by main +// before Execute(). Supported keys: "accounting", "invoice". +var EmbeddedSpecs = map[string][]byte{} + +const ( + apiAccounting = "accounting" + apiInvoice = "invoice" +) + var describeCmd = &cobra.Command{ Use: "describe ", Short: "Describe API operations for a resource", Long: `Show detailed API information for a resource, including available operations, parameters, required scopes, and endpoint paths. -Use "mf describe --list" to see all available resources.`, - Args: cobra.MaximumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { - s, err := schema.New(EmbeddedSpec) - if err != nil { - exitError(fmt.Errorf("failed to load API schema: %w", err), 1) - } +Use "mf describe --list" to see all available resources. +Use --api invoice to switch to the MoneyForward Invoice API spec instead +of the accounting one (which is the default).`, + Args: cobra.MaximumNArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { listFlag, _ := cmd.Flags().GetBool("list") - if listFlag { - resources := s.ListResources() - if format == "json" { - data, err := json.MarshalIndent(resources, "", " ") - if err != nil { - exitError(err, 1) - } - fmt.Fprintln(os.Stdout, string(data)) - } else { - headers := []string{"RESOURCE"} - var rows [][]string - for _, r := range resources { - rows = append(rows, []string{r}) - } - output.PrintTable(os.Stdout, headers, rows) - } - return - } + apiFlag, _ := cmd.Flags().GetString("api") - if len(args) == 0 { - exitError(fmt.Errorf("resource name is required; use --list to see available resources"), 1) + var resource string + if len(args) > 0 { + resource = args[0] } - info, err := s.Describe(args[0]) + err := runDescribe(specsForDescribe(), apiFlag, format, listFlag, resource, os.Stdout) if err != nil { exitError(err, 1) } + return nil + }, +} - if format == "table" { - printDescribeTable(info) - return +// specsForDescribe returns the spec map, falling back to the legacy +// EmbeddedSpec single variable for the accounting key when callers +// have not populated EmbeddedSpecs. +func specsForDescribe() map[string][]byte { + if len(EmbeddedSpecs) > 0 { + return EmbeddedSpecs + } + if len(EmbeddedSpec) > 0 { + return map[string][]byte{apiAccounting: EmbeddedSpec} + } + return nil +} + +// runDescribe is the pure, testable core of `mf describe`. It does not +// call os.Exit and writes all output to stdout. Errors are returned for +// the caller to format / exit on. +func runDescribe(specs map[string][]byte, apiName, formatName string, listFlag bool, resource string, stdout io.Writer) error { + if apiName == "" { + apiName = apiAccounting + } + + specBytes, ok := specs[apiName] + if !ok { + valid := make([]string, 0, len(specs)) + for name := range specs { + valid = append(valid, name) } + return fmt.Errorf("invalid --api %q: must be one of %v", apiName, valid) + } + if len(specBytes) == 0 { + return fmt.Errorf("OpenAPI spec for %q is not embedded", apiName) + } - data, err := json.MarshalIndent(info, "", " ") - if err != nil { - exitError(fmt.Errorf("failed to marshal result: %w", err), 1) + var ( + s *schema.Schema + err error + ) + switch apiName { + case apiAccounting: + s, err = schema.NewAccounting(specBytes) + case apiInvoice: + s, err = schema.NewInvoice(specBytes) + default: + return fmt.Errorf("invalid --api %q: must be %q or %q", apiName, apiAccounting, apiInvoice) + } + if err != nil { + return fmt.Errorf("failed to load API schema: %w", err) + } + + if listFlag { + resources := s.ListResources() + if formatName == "json" || formatName == "" { + data, err := json.MarshalIndent(resources, "", " ") + if err != nil { + return err + } + fmt.Fprintln(stdout, string(data)) + return nil } - fmt.Fprintln(os.Stdout, string(data)) - }, + headers := []string{"RESOURCE"} + var rows [][]string + for _, r := range resources { + rows = append(rows, []string{r}) + } + output.PrintTable(stdout, headers, rows) + return nil + } + + if resource == "" { + return fmt.Errorf("resource name is required; use --list to see available resources") + } + + info, err := s.Describe(resource) + if err != nil { + return err + } + + if formatName == "table" { + printDescribeTable(stdout, info) + return nil + } + + data, err := json.MarshalIndent(info, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal result: %w", err) + } + fmt.Fprintln(stdout, string(data)) + return nil } -func printDescribeTable(info *schema.ResourceInfo) { - fmt.Fprintf(os.Stdout, "Resource: %s\n\n", info.Resource) +func printDescribeTable(w io.Writer, info *schema.ResourceInfo) { + fmt.Fprintf(w, "Resource: %s\n\n", info.Resource) for i, op := range info.Operations { if i > 0 { - fmt.Fprintln(os.Stdout) + fmt.Fprintln(w) } - fmt.Fprintf(os.Stdout, " %s %s\n", op.Method, op.Path) + fmt.Fprintf(w, " %s %s\n", op.Method, op.Path) if op.Summary != "" { - fmt.Fprintf(os.Stdout, " Summary: %s\n", op.Summary) + fmt.Fprintf(w, " Summary: %s\n", op.Summary) } if op.RequiredScope != "" { - fmt.Fprintf(os.Stdout, " Scope: %s\n", op.RequiredScope) + fmt.Fprintf(w, " Scope: %s\n", op.RequiredScope) } if op.HasRequestBody { - fmt.Fprintf(os.Stdout, " Body: required\n") + fmt.Fprintf(w, " Body: required\n") + } + if op.RequestBodySchemaRef != "" { + fmt.Fprintf(w, " Body schema: %s\n", op.RequestBodySchemaRef) } if len(op.Parameters) > 0 { - fmt.Fprintf(os.Stdout, " Parameters:\n") + fmt.Fprintf(w, " Parameters:\n") headers := []string{"NAME", "IN", "REQUIRED", "TYPE"} var rows [][]string for _, p := range op.Parameters { @@ -99,15 +178,13 @@ func printDescribeTable(info *schema.ResourceInfo) { p.Type, }) } - // Print as aligned columns. - printParamTable(os.Stdout, headers, rows) + printParamTable(w, headers, rows) } } } // printParamTable prints a simple aligned table indented for parameter display. -func printParamTable(w *os.File, headers []string, rows [][]string) { - // Calculate column widths. +func printParamTable(w io.Writer, headers []string, rows [][]string) { widths := make([]int, len(headers)) for i, h := range headers { widths[i] = len(h) @@ -120,21 +197,18 @@ func printParamTable(w *os.File, headers []string, rows [][]string) { } } - // Print header. var headerParts []string for i, h := range headers { headerParts = append(headerParts, fmt.Sprintf("%-*s", widths[i], h)) } fmt.Fprintf(w, " %s\n", strings.Join(headerParts, " ")) - // Print separator. var sepParts []string for _, width := range widths { sepParts = append(sepParts, strings.Repeat("-", width)) } fmt.Fprintf(w, " %s\n", strings.Join(sepParts, " ")) - // Print rows. for _, row := range rows { var parts []string for i, cell := range row { @@ -148,5 +222,6 @@ func printParamTable(w *os.File, headers []string, rows [][]string) { func init() { describeCmd.Flags().Bool("list", false, "list all available resources") + describeCmd.Flags().String("api", apiAccounting, "API spec to describe (accounting|invoice)") rootCmd.AddCommand(describeCmd) } diff --git a/cmd/describe_test.go b/cmd/describe_test.go new file mode 100644 index 0000000..b031473 --- /dev/null +++ b/cmd/describe_test.go @@ -0,0 +1,129 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" +) + +const minimalAccountingSpec = ` +openapi: "3.0.0" +info: + title: Accounting + version: "1.0" +paths: + /api/v3/journals: + get: + operationId: list-journals + summary: List journals + security: + - OAuth2: + - mfc/accounting/journal.read +` + +const minimalInvoiceSpec = ` +openapi: 3.1.0 +info: + title: Invoice + version: "1.0" +servers: + - url: /api/v3 +paths: + /billings: + get: + operationId: get-billings + summary: Get billings + security: + - AccessToken: + - mfc/invoice/data.read + /invoice_template_billings: + post: + operationId: post-invoice-template-billings + summary: Create invoice + requestBody: + $ref: '#/components/requestBodies/BillingNewTemplateCreateRequest' + security: + - AccessToken: + - mfc/invoice/data.write +components: + requestBodies: + BillingNewTemplateCreateRequest: + content: + application/json: + schema: + $ref: '#/components/schemas/BillingNewTemplate' + schemas: + BillingNewTemplate: + type: object +` + +func testSpecs() map[string][]byte { + return map[string][]byte{ + apiAccounting: []byte(minimalAccountingSpec), + apiInvoice: []byte(minimalInvoiceSpec), + } +} + +func TestRunDescribe_InvoiceList(t *testing.T) { + var buf bytes.Buffer + if err := runDescribe(testSpecs(), apiInvoice, "json", true, "", &buf); err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := buf.String() + for _, name := range []string{"invoices", "invoice-partners", "invoice-departments", "invoice-items"} { + if !strings.Contains(got, name) { + t.Errorf("output missing invoice resource %q: %s", name, got) + } + } +} + +func TestRunDescribe_InvoiceShowsRequestBodyRef(t *testing.T) { + var buf bytes.Buffer + if err := runDescribe(testSpecs(), apiInvoice, "json", false, "invoices", &buf); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(buf.String(), "BillingNewTemplate") { + t.Errorf("expected BillingNewTemplate in describe output, got: %s", buf.String()) + } +} + +func TestRunDescribe_AccountingDefault(t *testing.T) { + var buf bytes.Buffer + // Empty apiName should default to accounting. + if err := runDescribe(testSpecs(), "", "json", true, "", &buf); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + // invoice-prefixed names must NOT appear in accounting list. + if strings.Contains(out, "invoice-") { + t.Errorf("accounting list unexpectedly includes invoice resources: %s", out) + } +} + +func TestRunDescribe_InvalidAPI(t *testing.T) { + var buf bytes.Buffer + err := runDescribe(testSpecs(), "foo", "json", true, "", &buf) + if err == nil { + t.Fatal("expected error for unknown --api value") + } + if !strings.Contains(err.Error(), "invalid --api") { + t.Errorf("error should mention invalid --api, got: %v", err) + } +} + +func TestRunDescribe_ResourceRequiredWithoutList(t *testing.T) { + var buf bytes.Buffer + err := runDescribe(testSpecs(), apiAccounting, "json", false, "", &buf) + if err == nil { + t.Fatal("expected error when no resource and no --list") + } +} + +func TestRunDescribe_MissingSpec(t *testing.T) { + specs := map[string][]byte{apiAccounting: []byte(minimalAccountingSpec)} + var buf bytes.Buffer + err := runDescribe(specs, apiInvoice, "json", true, "", &buf) + if err == nil { + t.Fatal("expected error when invoice spec is missing from map") + } +} diff --git a/cmd/helpers.go b/cmd/helpers.go index e90b3fc..5070e59 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -6,7 +6,9 @@ import ( "errors" "fmt" "io" + "net/url" "os" + "strings" "github.com/beatinaniwa/mf-cli/internal/api" "github.com/beatinaniwa/mf-cli/internal/output" @@ -57,8 +59,8 @@ func exitError(err error, code int) { os.Exit(code) } -func boolPtr(b bool) *bool { return &b } -func intPtr(i int) *int { return &i } +func boolPtr(b bool) *bool { return &b } +func intPtr(i int) *int { return &i } func readAndUnmarshalJSON(jsonFlag string, v any) error { data, err := readJSONInput(jsonFlag) @@ -83,3 +85,177 @@ func handleDryRun(client *api.Client, method, path string, body any) { fmt.Fprintln(os.Stdout, string(data)) } +// --------------------------------------------------------------------------- +// Helpers introduced for the new invoice command pattern. +// +// These accept an io.Writer and never call os.Exit, so pure runner +// functions can be unit tested with a bytes.Buffer. They live alongside +// the legacy globals-based helpers above for backwards compatibility. +// --------------------------------------------------------------------------- + +// readJSONInputFrom returns JSON bytes. When jsonFlag is "-" it reads +// from r (typically a stdin handle injected via cmdDeps). Otherwise it +// returns the flag value as-is. Filename support is intentionally not +// added — users can pipe via `$(cat file.json)` on the shell. +func readJSONInputFrom(r io.Reader, jsonFlag string) ([]byte, error) { + if jsonFlag == "-" { + if r == nil { + r = os.Stdin + } + return io.ReadAll(r) + } + return []byte(jsonFlag), nil +} + +// readAndUnmarshalJSONUseNumber decodes JSON with UseNumber so numeric +// literals are preserved as json.Number strings rather than coerced to +// float64. Used by invoice create/update to keep monetary precision. +// +// We also reject inputs that contain extra data after the first JSON +// value. json.Decoder silently stops after the first value, so e.g. +// `{"a":1}garbage`, `{"a":1}{"b":2}`, or even a stray `]`/`}` would +// otherwise pass. dec.More() is not enough — it returns false on a +// closing delimiter — so we attempt a second Decode and require io.EOF. +func readAndUnmarshalJSONUseNumber(r io.Reader, jsonFlag string, v any) error { + data, err := readJSONInputFrom(r, jsonFlag) + if err != nil { + return fmt.Errorf("reading JSON input: %w", err) + } + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + // Reject unknown fields so a typo or a field that the target + // endpoint does not accept (e.g. `items` on UpdateBillingRequest) + // surfaces immediately instead of being silently dropped. + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + var trailing json.RawMessage + switch err := dec.Decode(&trailing); err { + case io.EOF: + // expected: nothing beyond the first value + case nil: + return fmt.Errorf("invalid JSON: unexpected trailing data after first value") + default: + return fmt.Errorf("invalid JSON: %w", err) + } + return nil +} + +// outputResultTo writes a JSON or table representation of data to w. It +// returns errors instead of calling os.Exit, so the caller controls +// the failure path. +// +// Behavior matches the legacy outputResult: when format=="table" and +// tableRenderer is non-nil, fields filtering is skipped (table +// renderers expect the full envelope). Otherwise --fields is applied +// and the result is pretty-printed JSON. +func outputResultTo(w io.Writer, data []byte, formatName, fields string, tableRenderer func(io.Writer, []byte) error) error { + if formatName == "table" && tableRenderer != nil { + return tableRenderer(w, data) + } + if fields != "" { + filtered, err := api.FilterFields(data, fields) + if err != nil { + return err + } + data = filtered + } + var buf bytes.Buffer + if err := json.Indent(&buf, data, "", " "); err != nil { + _, _ = fmt.Fprintln(w, string(data)) + return nil + } + _, _ = fmt.Fprintln(w, buf.String()) + return nil +} + +// handleDryRunTo writes a dry-run output to w using the supplied +// builder. It is the no-os.Exit counterpart to handleDryRun and is +// intended for the new pure runners. +func handleDryRunTo(w io.Writer, builder DryRunBuilder, method, path string, body any) error { + out, err := builder.BuildRequest(method, path, nil, body) + if err != nil { + return err + } + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return fmt.Errorf("marshaling dry-run output: %w", err) + } + _, _ = fmt.Fprintln(w, string(data)) + return nil +} + +// formatInvoiceAPIError inspects err for OAuth2 insufficient_scope +// signals. When found it returns a friendly remediation message and +// replaced=true; otherwise it returns ("", false) and the caller +// should fall back to the original error message. +// +// Only an explicit `insufficient_scope` or `invalid_scope` code in an +// API error body counts. A generic 403 is left alone — that may be a +// tenant permission or resource access denial unrelated to OAuth. +func formatInvoiceAPIError(err error) (string, bool) { + var apiErr *api.APIError + if !errors.As(err, &apiErr) { + return "", false + } + if !invoiceScopeSignal(apiErr) { + return "", false + } + return "your token does not include the required invoice scope. " + + "Run `unset MF_SCOPES && mf auth login --scopes all` to re-authenticate with both invoice scopes.", true +} + +func invoiceScopeSignal(apiErr *api.APIError) bool { + for _, e := range apiErr.Errors { + if isInsufficientScopeCode(e.Code) { + return true + } + } + // Last resort: a quick substring check on the raw response in case + // the OAuth body landed without a recognized code mapping. + raw := apiErr.RawResponse + if raw == "" { + return false + } + return strings.Contains(raw, "insufficient_scope") || strings.Contains(raw, "invalid_scope") +} + +func isInsufficientScopeCode(code string) bool { + switch code { + case "insufficient_scope", "invalid_scope": + return true + } + return false +} + +// exitInvoiceAPIError exits with a friendly insufficient-scope hint +// when applicable, otherwise delegates to exitError. +// +// When formatInvoiceAPIError matches, we surface the friendly message +// as the top-level error rather than wrapping the original *api.APIError +// — exitError uses APIError.Error() (which contains low-level detail +// like the raw OAuth body) when errors.As succeeds, which would bury +// the remediation hint. We still preserve the underlying status / fields +// by writing them through output.PrintError directly so agents can +// inspect them. +func exitInvoiceAPIError(err error) { + if msg, ok := formatInvoiceAPIError(err); ok { + var apiErr *api.APIError + if errors.As(err, &apiErr) { + output.PrintError(os.Stderr, msg, apiErr.StatusCode, apiErr.Operation, apiErr.Errors, apiErr.RawResponse) + } else { + output.PrintError(os.Stderr, msg, 0, "", nil, "") + } + os.Exit(1) + return + } + exitError(err, 1) +} + +// DryRunBuilder is the minimal interface needed by handleDryRunTo. It +// matches *api.Client.BuildRequest so the production client satisfies +// it without an adapter; tests can supply a fake. +type DryRunBuilder interface { + BuildRequest(method, path string, query url.Values, body any) (*api.DryRunOutput, error) +} diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go new file mode 100644 index 0000000..7905b47 --- /dev/null +++ b/cmd/helpers_test.go @@ -0,0 +1,196 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/url" + "strings" + "testing" + + "github.com/beatinaniwa/mf-cli/internal/api" + "github.com/beatinaniwa/mf-cli/internal/model" +) + +func TestReadJSONInputFrom_DirectJSON(t *testing.T) { + got, err := readJSONInputFrom(nil, `{"a":1}`) + if err != nil { + t.Fatal(err) + } + if string(got) != `{"a":1}` { + t.Errorf("got %s", got) + } +} + +func TestReadJSONInputFrom_StdinSentinel(t *testing.T) { + got, err := readJSONInputFrom(strings.NewReader(`{"b":2}`), "-") + if err != nil { + t.Fatal(err) + } + if string(got) != `{"b":2}` { + t.Errorf("got %s", got) + } +} + +func TestReadAndUnmarshalJSONUseNumber_PreservesPrecision(t *testing.T) { + type item struct { + Price json.Number `json:"price"` + } + var v item + err := readAndUnmarshalJSONUseNumber(nil, `{"price":10000.25}`, &v) + if err != nil { + t.Fatalf("err = %v", err) + } + if string(v.Price) != "10000.25" { + t.Errorf("price = %q, want 10000.25", v.Price) + } +} + +func TestReadAndUnmarshalJSONUseNumber_InvalidJSON(t *testing.T) { + var v map[string]any + err := readAndUnmarshalJSONUseNumber(nil, `{nope`, &v) + if err == nil { + t.Fatal("expected error") + } +} + +func TestReadAndUnmarshalJSONUseNumber_RejectsTrailingData(t *testing.T) { + cases := []string{ + `{"a":1}{"b":2}`, // concatenated objects + `{"a":1}garbage`, // garbage after value + `{"a":1}]`, // stray closing delimiter + `{"a":1}}`, // stray closing brace + `{"a":1} extra`, // text after whitespace + } + for _, in := range cases { + var v map[string]any + err := readAndUnmarshalJSONUseNumber(nil, in, &v) + if err == nil { + t.Errorf("expected error for trailing data in %q", in) + } + } +} + +func TestReadAndUnmarshalJSONUseNumber_RejectsUnknownFields(t *testing.T) { + // e.g. supplying "items" to UpdateBillingRequest must error rather + // than be silently dropped, otherwise users think they updated + // line items when nothing happened. + type withTitleOnly struct { + Title *string `json:"title,omitempty"` + } + var v withTitleOnly + err := readAndUnmarshalJSONUseNumber(nil, `{"title":"x","items":[]}`, &v) + if err == nil { + t.Fatal("expected error for unknown field") + } + if !strings.Contains(err.Error(), "items") { + t.Errorf("error should mention the unknown field, got: %v", err) + } +} + +func TestReadAndUnmarshalJSONUseNumber_AcceptsTrailingWhitespace(t *testing.T) { + var v map[string]any + if err := readAndUnmarshalJSONUseNumber(nil, "{\"a\":1}\n \t", &v); err != nil { + t.Errorf("trailing whitespace should be allowed, got: %v", err) + } +} + +func TestOutputResultTo_JSONIndent(t *testing.T) { + var buf bytes.Buffer + if err := outputResultTo(&buf, []byte(`{"a":1}`), "json", "", nil); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "\"a\": 1") { + t.Errorf("not pretty-printed: %s", buf.String()) + } +} + +func TestOutputResultTo_TableSkipsFields(t *testing.T) { + var buf bytes.Buffer + called := false + renderer := func(w io.Writer, data []byte) error { + called = true + _, _ = w.Write([]byte("TABLE")) + return nil + } + err := outputResultTo(&buf, []byte(`{"x":1}`), "table", "x", renderer) + if err != nil { + t.Fatal(err) + } + if !called { + t.Error("renderer not invoked") + } + if buf.String() != "TABLE" { + t.Errorf("buf = %q", buf.String()) + } +} + +func TestFormatInvoiceAPIError_InsufficientScopeCode(t *testing.T) { + apiErr := &api.APIError{ + StatusCode: 403, + Errors: []model.ErrorDetail{{Code: "insufficient_scope", Message: "missing scope"}}, + } + msg, ok := formatInvoiceAPIError(apiErr) + if !ok { + t.Fatal("expected replacement") + } + if !strings.Contains(msg, "mf auth login --scopes all") { + t.Errorf("msg = %q", msg) + } +} + +func TestFormatInvoiceAPIError_RawResponseInsufficientScope(t *testing.T) { + apiErr := &api.APIError{ + StatusCode: 403, + RawResponse: `{"error":"insufficient_scope"}`, + } + if _, ok := formatInvoiceAPIError(apiErr); !ok { + t.Error("expected replacement from raw response") + } +} + +func TestFormatInvoiceAPIError_GenericForbiddenNotReplaced(t *testing.T) { + apiErr := &api.APIError{ + StatusCode: 403, + Errors: []model.ErrorDetail{{Code: "forbidden", Message: "permission denied"}}, + } + if _, ok := formatInvoiceAPIError(apiErr); ok { + t.Error("generic 403 should not be replaced") + } +} + +func TestFormatInvoiceAPIError_NotAnAPIError(t *testing.T) { + if _, ok := formatInvoiceAPIError(errors.New("plain")); ok { + t.Error("non-APIError should not be replaced") + } +} + +// fakeBuilder satisfies DryRunBuilder for testing handleDryRunTo. +type fakeBuilder struct { + gotMethod string + gotPath string + gotBody any +} + +func (f *fakeBuilder) BuildRequest(method, path string, _ url.Values, body any) (*api.DryRunOutput, error) { + f.gotMethod = method + f.gotPath = path + f.gotBody = body + return &api.DryRunOutput{Method: method, URL: "https://test" + path}, nil +} + +func TestHandleDryRunTo_WritesJSON(t *testing.T) { + var buf bytes.Buffer + b := &fakeBuilder{} + if err := handleDryRunTo(&buf, b, "POST", "/api/v3/invoice_template_billings", map[string]string{"x": "y"}); err != nil { + t.Fatal(err) + } + out := buf.String() + if !strings.Contains(out, `"method": "POST"`) { + t.Errorf("missing method: %s", out) + } + if b.gotMethod != "POST" || b.gotPath != "/api/v3/invoice_template_billings" { + t.Errorf("builder not called correctly: %+v", b) + } +} diff --git a/cmd/invoice_departments.go b/cmd/invoice_departments.go new file mode 100644 index 0000000..20596ae --- /dev/null +++ b/cmd/invoice_departments.go @@ -0,0 +1,131 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/beatinaniwa/mf-cli/internal/api" + "github.com/spf13/cobra" +) + +func newInvoiceDepartmentsCmd(deps cmdDeps) *cobra.Command { + root := &cobra.Command{ + Use: "invoice-departments", + Short: "List or fetch invoice partner departments", + SilenceUsage: true, + SilenceErrors: true, + } + root.AddCommand(newInvoiceDepartmentsListCmd(deps)) + root.AddCommand(newInvoiceDepartmentsGetCmd(deps)) + return root +} + +func newInvoiceDepartmentsListCmd(deps cmdDeps) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List departments under a partner (--partner-id required)", + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + opts, err := parseInvoiceDepartmentsListOptions(cmd, args) + if err != nil { + return err + } + if err := runInvoiceDepartmentsList(deps, opts); err != nil { + exitInvoiceAPIError(err) + } + return nil + }, + } + addInvoicePaginationFlags(cmd) + cmd.Flags().String("partner-id", "", "parent partner ID (required)") + return cmd +} + +func runInvoiceDepartmentsList(deps cmdDeps, opts invoiceDepartmentsListOptions) error { + if strings.TrimSpace(opts.PartnerID) == "" { + return fmt.Errorf("--partner-id is required") + } + if err := validateInvoicePagination(opts.Page, opts.PerPage); err != nil { + return err + } + cfg, err := deps.loadConfig() + if err != nil { + return err + } + tok, err := deps.loadToken(cfg) + if err == nil { + if err := requireInvoiceScope(tok, invoiceReadScopes...); err != nil { + return err + } + } + client, err := deps.newClient(cfg) + if err != nil { + return err + } + params := api.DepartmentListParams{Page: opts.Page, PerPage: opts.PerPage} + data, err := client.GetPartnerDepartments(context.Background(), opts.PartnerID, params) + if err != nil { + return err + } + return outputResultTo(deps.stdout, data, opts.Format, opts.Fields, departmentsTableRenderer) +} + +func newInvoiceDepartmentsGetCmd(deps cmdDeps) *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Get a single department under a partner (--partner-id required)", + Args: cobra.MaximumNArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + opts, err := parseInvoiceDepartmentsGetOptions(cmd, args) + if err != nil { + return err + } + if err := runInvoiceDepartmentsGet(deps, opts); err != nil { + exitInvoiceAPIError(err) + } + return nil + }, + } + cmd.Flags().String("partner-id", "", "parent partner ID (required)") + return cmd +} + +func runInvoiceDepartmentsGet(deps cmdDeps, opts invoiceDepartmentsGetOptions) error { + if strings.TrimSpace(opts.PartnerID) == "" { + return fmt.Errorf("--partner-id is required") + } + if strings.TrimSpace(opts.DepartmentID) == "" { + return fmt.Errorf("department id is required") + } + cfg, err := deps.loadConfig() + if err != nil { + return err + } + tok, err := deps.loadToken(cfg) + if err == nil { + if err := requireInvoiceScope(tok, invoiceReadScopes...); err != nil { + return err + } + } + client, err := deps.newClient(cfg) + if err != nil { + return err + } + data, err := client.GetPartnerDepartment(context.Background(), opts.PartnerID, opts.DepartmentID) + if err != nil { + return err + } + return outputResultTo(deps.stdout, data, opts.Format, opts.Fields, nil) +} + +func departmentsTableRenderer(w io.Writer, data []byte) error { + return renderInvoiceTable(w, data, + []string{"ID", "PERSON_NAME", "EMAIL", "TEL"}, + []string{"id", "person_name", "email", "tel"}, + ) +} diff --git a/cmd/invoice_departments_test.go b/cmd/invoice_departments_test.go new file mode 100644 index 0000000..b81f481 --- /dev/null +++ b/cmd/invoice_departments_test.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "testing" +) + +func TestRunInvoiceDepartmentsList_RequiresPartnerID(t *testing.T) { + deps, _, _ := invoiceTestDeps(&fakeInvoiceClient{}, nil, readScopes(), "") + if err := runInvoiceDepartmentsList(deps, invoiceDepartmentsListOptions{}); err == nil { + t.Error("expected --partner-id required error") + } +} + +func TestRunInvoiceDepartmentsList_HappyPath(t *testing.T) { + client := &fakeInvoiceClient{listResp: []byte(`{"data":[]}`)} + deps, _, _ := invoiceTestDeps(client, nil, readScopes(), "") + opts := invoiceDepartmentsListOptions{ + invoiceCommonOptions: invoiceCommonOptions{Format: "json"}, + PartnerID: "p1", + } + if err := runInvoiceDepartmentsList(deps, opts); err != nil { + t.Fatalf("err = %v", err) + } + if client.gotPartnerID != "p1" { + t.Errorf("partner id = %q", client.gotPartnerID) + } +} + +func TestRunInvoiceDepartmentsGet_RequiresBoth(t *testing.T) { + deps, _, _ := invoiceTestDeps(&fakeInvoiceClient{}, nil, readScopes(), "") + if err := runInvoiceDepartmentsGet(deps, invoiceDepartmentsGetOptions{DepartmentID: "d"}); err == nil { + t.Error("expected partner_id required error") + } + if err := runInvoiceDepartmentsGet(deps, invoiceDepartmentsGetOptions{PartnerID: "p"}); err == nil { + t.Error("expected department_id required error") + } +} diff --git a/cmd/invoice_deps.go b/cmd/invoice_deps.go new file mode 100644 index 0000000..854a960 --- /dev/null +++ b/cmd/invoice_deps.go @@ -0,0 +1,230 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/beatinaniwa/mf-cli/internal/api" + "github.com/beatinaniwa/mf-cli/internal/auth" + "github.com/beatinaniwa/mf-cli/internal/config" + invoicemodel "github.com/beatinaniwa/mf-cli/internal/model/invoice" + "github.com/beatinaniwa/mf-cli/internal/output" + "github.com/spf13/cobra" +) + +// Required-scope sets used by invoice command preflights. `data.write` +// is documented to grant both reference and update access, so read +// commands accept either scope. +var ( + invoiceReadScopes = []string{auth.ScopeInvoiceRead, auth.ScopeInvoiceWrite} + invoiceWriteScopes = []string{auth.ScopeInvoiceWrite} +) + +// Client interfaces are defined here in the consumer package so +// internal/api stays focused on concrete operations; *api.Client +// satisfies each interface implicitly so production wiring needs no +// adapter and tests can substitute fakes freely. + +// BillingClient covers the invoice billings endpoints. +type BillingClient interface { + GetBillings(ctx context.Context, params api.BillingListParams) ([]byte, error) + GetBilling(ctx context.Context, id string) ([]byte, error) + CreateBilling(ctx context.Context, req *invoicemodel.CreateBillingRequest) ([]byte, error) + UpdateBilling(ctx context.Context, id string, req *invoicemodel.UpdateBillingRequest) ([]byte, error) + DeleteBilling(ctx context.Context, id string) error +} + +// PartnerClient covers the invoice partners endpoints. +type PartnerClient interface { + GetPartners(ctx context.Context, params api.PartnerListParams) ([]byte, error) + GetPartner(ctx context.Context, id string) ([]byte, error) +} + +// DepartmentClient covers the invoice departments endpoints. +type DepartmentClient interface { + GetPartnerDepartments(ctx context.Context, partnerID string, params api.DepartmentListParams) ([]byte, error) + GetPartnerDepartment(ctx context.Context, partnerID, departmentID string) ([]byte, error) +} + +// ItemClient covers the invoice items endpoints. +type ItemClient interface { + GetItems(ctx context.Context, params api.ItemListParams) ([]byte, error) + GetItem(ctx context.Context, id string) ([]byte, error) +} + +// InvoiceClient is the aggregate interface returned by cmdDeps.newClient. +type InvoiceClient interface { + BillingClient + PartnerClient + DepartmentClient + ItemClient +} + +// cmdDeps groups the side effects an invoice command runner needs. The +// fields are factories rather than concrete values because RunE fires +// after flags are parsed, and we want each invocation to load fresh +// config and tokens (without forcing tests through that machinery). +type cmdDeps struct { + loadConfig func() (*config.Config, error) + newClient func(cfg *config.Config) (InvoiceClient, error) + newDryRunBuilder func(cfg *config.Config) (DryRunBuilder, error) + loadToken func(cfg *config.Config) (*auth.Token, error) + stdin io.Reader + stdout io.Writer + stderr io.Writer +} + +// productionDeps returns the standard wiring used by the real CLI. +func productionDeps() cmdDeps { + return cmdDeps{ + loadConfig: config.Load, + newClient: func(cfg *config.Config) (InvoiceClient, error) { + return api.NewInvoiceClient(cfg, debug) + }, + newDryRunBuilder: func(cfg *config.Config) (DryRunBuilder, error) { + return api.NewInvoiceClient(cfg, debug) + }, + loadToken: auth.LoadToken, + stdin: os.Stdin, + stdout: os.Stdout, + stderr: os.Stderr, + } +} + +// addInvoiceCommands registers all invoice subcommands under root. +// Implemented as a function (rather than init()-only registration) so +// tests can build an isolated root and inspect what was attached +// without touching the package-global rootCmd. +func addInvoiceCommands(root *cobra.Command, deps cmdDeps) { + root.AddCommand(newInvoicesCmd(deps)) + root.AddCommand(newInvoicePartnersCmd(deps)) + root.AddCommand(newInvoiceDepartmentsCmd(deps)) + root.AddCommand(newInvoiceItemsCmd(deps)) +} + +// invoiceCommonOptions captures the persistent flags every invoice +// command needs to read out of the cobra layer. +type invoiceCommonOptions struct { + Format string + Fields string + Debug bool +} + +func parseInvoiceCommonOptions(cmd *cobra.Command) invoiceCommonOptions { + getStr := func(name, fallback string) string { + if v, err := cmd.Flags().GetString(name); err == nil && v != "" { + return v + } + return fallback + } + getBool := func(name string) bool { + if cmd.Flags().Lookup(name) == nil { + return false + } + v, _ := cmd.Flags().GetBool(name) + return v + } + return invoiceCommonOptions{ + Format: getStr("format", "json"), + Fields: getStr("fields", ""), + Debug: getBool("debug"), + } +} + +// requireInvoiceScope confirms the saved token carries one of the +// supplied scopes. An empty scope list on the token is treated as +// "unknown" and allowed through — api.Client.Do still gets a chance to +// surface the API's insufficient_scope response. +func requireInvoiceScope(token *auth.Token, scopes ...string) error { + if token == nil || len(token.Scopes) == 0 { + return nil + } + if auth.HasAnyScope(token, scopes...) { + return nil + } + return &invoiceScopeError{requiredScopes: scopes} +} + +type invoiceScopeError struct { + requiredScopes []string +} + +func (e *invoiceScopeError) Error() string { + return "your token does not include the required invoice scope (" + + strings.Join(e.requiredScopes, " or ") + + "). Run `unset MF_SCOPES && mf auth login --scopes all` to re-authenticate with both invoice scopes." +} + +// addInvoicePaginationFlags registers the standard --page / --per-page +// flags on cmd. All four invoice list commands use the same shape, so +// keep their definitions in lockstep. +func addInvoicePaginationFlags(cmd *cobra.Command) { + cmd.Flags().Int("page", 0, "page number (1-based)") + cmd.Flags().Int("per-page", 0, "page size (1..100)") +} + +// parseInvoicePaginationFlags reads --page / --per-page, returning +// pointers so callers can distinguish "not provided" from a value of 0. +func parseInvoicePaginationFlags(cmd *cobra.Command) (page, perPage *int) { + if cmd.Flags().Changed("page") { + v, _ := cmd.Flags().GetInt("page") + page = &v + } + if cmd.Flags().Changed("per-page") { + v, _ := cmd.Flags().GetInt("per-page") + perPage = &v + } + return page, perPage +} + +// validateInvoicePagination enforces the bounds the API documents for +// page (>=1) and per_page (1..100). +func validateInvoicePagination(page, perPage *int) error { + if page != nil && *page < 1 { + return fmt.Errorf("--page must be >= 1") + } + if perPage != nil && (*perPage < 1 || *perPage > 100) { + return fmt.Errorf("--per-page must be between 1 and 100") + } + return nil +} + +// renderInvoiceTable is the shared table renderer for invoice list +// responses. The Invoice API wraps every list payload in +// {"data":[...], "pagination":{...}}; we extract data, then build rows +// by reading fieldKeys[i] out of each row map. +// +// fieldKeys must be the same length as headers; missing or nil values +// render as the empty string. +func renderInvoiceTable(w io.Writer, data []byte, headers, fieldKeys []string) error { + var resp struct { + Data []map[string]any `json:"data"` + } + if err := json.Unmarshal(data, &resp); err != nil { + _, _ = w.Write(data) + return nil + } + rows := make([][]string, 0, len(resp.Data)) + for _, row := range resp.Data { + cells := make([]string, len(fieldKeys)) + for i, key := range fieldKeys { + cells[i] = stringifyCell(row[key]) + } + rows = append(rows, cells) + } + output.PrintTable(w, headers, rows) + return nil +} + +// stringifyCell renders a JSON-decoded cell for table output, treating +// nil as the empty string. +func stringifyCell(v any) string { + if v == nil { + return "" + } + return fmt.Sprintf("%v", v) +} diff --git a/cmd/invoice_items.go b/cmd/invoice_items.go new file mode 100644 index 0000000..a17adad --- /dev/null +++ b/cmd/invoice_items.go @@ -0,0 +1,124 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/beatinaniwa/mf-cli/internal/api" + "github.com/spf13/cobra" +) + +func newInvoiceItemsCmd(deps cmdDeps) *cobra.Command { + root := &cobra.Command{ + Use: "invoice-items", + Short: "List or fetch MoneyForward Invoice API items", + SilenceUsage: true, + SilenceErrors: true, + } + root.AddCommand(newInvoiceItemsListCmd(deps)) + root.AddCommand(newInvoiceItemsGetCmd(deps)) + return root +} + +func newInvoiceItemsListCmd(deps cmdDeps) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List invoice items", + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + opts, err := parseInvoiceItemsListOptions(cmd, args) + if err != nil { + return err + } + if err := runInvoiceItemsList(deps, opts); err != nil { + exitInvoiceAPIError(err) + } + return nil + }, + } + addInvoicePaginationFlags(cmd) + cmd.Flags().String("name", "", "item name filter (CSV-supported)") + cmd.Flags().String("code", "", "item code filter (CSV-supported)") + return cmd +} + +func runInvoiceItemsList(deps cmdDeps, opts invoiceItemsListOptions) error { + if err := validateInvoicePagination(opts.Page, opts.PerPage); err != nil { + return err + } + cfg, err := deps.loadConfig() + if err != nil { + return err + } + tok, err := deps.loadToken(cfg) + if err == nil { + if err := requireInvoiceScope(tok, invoiceReadScopes...); err != nil { + return err + } + } + client, err := deps.newClient(cfg) + if err != nil { + return err + } + params := api.ItemListParams{Page: opts.Page, PerPage: opts.PerPage, Name: opts.Name, Code: opts.Code} + data, err := client.GetItems(context.Background(), params) + if err != nil { + return err + } + return outputResultTo(deps.stdout, data, opts.Format, opts.Fields, itemsTableRenderer) +} + +func newInvoiceItemsGetCmd(deps cmdDeps) *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Get a single invoice item by ID", + Args: cobra.MaximumNArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + opts, err := parseInvoiceItemsGetOptions(cmd, args) + if err != nil { + return err + } + if err := runInvoiceItemsGet(deps, opts); err != nil { + exitInvoiceAPIError(err) + } + return nil + }, + } +} + +func runInvoiceItemsGet(deps cmdDeps, opts invoiceItemsGetOptions) error { + if strings.TrimSpace(opts.ID) == "" { + return fmt.Errorf("item id is required") + } + cfg, err := deps.loadConfig() + if err != nil { + return err + } + tok, err := deps.loadToken(cfg) + if err == nil { + if err := requireInvoiceScope(tok, invoiceReadScopes...); err != nil { + return err + } + } + client, err := deps.newClient(cfg) + if err != nil { + return err + } + data, err := client.GetItem(context.Background(), opts.ID) + if err != nil { + return err + } + return outputResultTo(deps.stdout, data, opts.Format, opts.Fields, nil) +} + +func itemsTableRenderer(w io.Writer, data []byte) error { + return renderInvoiceTable(w, data, + []string{"ID", "CODE", "NAME", "PRICE", "EXCISE"}, + []string{"id", "code", "name", "price", "excise"}, + ) +} diff --git a/cmd/invoice_items_test.go b/cmd/invoice_items_test.go new file mode 100644 index 0000000..32b96c8 --- /dev/null +++ b/cmd/invoice_items_test.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "testing" + + "github.com/beatinaniwa/mf-cli/internal/api" +) + +func TestRunInvoiceItemsList_PassesFilters(t *testing.T) { + client := &fakeInvoiceClient{listResp: []byte(`{"data":[]}`)} + deps, _, _ := invoiceTestDeps(client, nil, readScopes(), "") + opts := invoiceItemsListOptions{ + invoiceCommonOptions: invoiceCommonOptions{Format: "json"}, + Name: "Consult", Code: "C-1", + } + if err := runInvoiceItemsList(deps, opts); err != nil { + t.Fatalf("err = %v", err) + } + got := client.gotListParams.(api.ItemListParams) + if got.Name != "Consult" || got.Code != "C-1" { + t.Errorf("filters not propagated: %+v", got) + } +} + +func TestRunInvoiceItemsGet_BlankID(t *testing.T) { + deps, _, _ := invoiceTestDeps(&fakeInvoiceClient{}, nil, readScopes(), "") + if err := runInvoiceItemsGet(deps, invoiceItemsGetOptions{}); err == nil { + t.Error("expected blank id error") + } +} diff --git a/cmd/invoice_partners.go b/cmd/invoice_partners.go new file mode 100644 index 0000000..9e6e23c --- /dev/null +++ b/cmd/invoice_partners.go @@ -0,0 +1,131 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/beatinaniwa/mf-cli/internal/api" + "github.com/spf13/cobra" +) + +func newInvoicePartnersCmd(deps cmdDeps) *cobra.Command { + root := &cobra.Command{ + Use: "invoice-partners", + Short: "List or fetch MoneyForward Invoice API partners", + SilenceUsage: true, + SilenceErrors: true, + } + root.AddCommand(newInvoicePartnersListCmd(deps)) + root.AddCommand(newInvoicePartnersGetCmd(deps)) + return root +} + +func newInvoicePartnersListCmd(deps cmdDeps) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List invoice partners", + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + opts, err := parseInvoicePartnersListOptions(cmd, args) + if err != nil { + return err + } + if err := runInvoicePartnersList(deps, opts); err != nil { + exitInvoiceAPIError(err) + } + return nil + }, + } + addInvoicePaginationFlags(cmd) + cmd.Flags().String("name", "", "partner name filter (CSV-supported)") + cmd.Flags().String("code", "", "partner code filter (CSV-supported)") + cmd.Flags().String("name-kana", "", "partner katakana name filter") + cmd.Flags().String("partner-pic", "", "partner person-in-charge filter") + cmd.Flags().String("office-pic", "", "office person-in-charge filter") + return cmd +} + +func runInvoicePartnersList(deps cmdDeps, opts invoicePartnersListOptions) error { + if err := validateInvoicePagination(opts.Page, opts.PerPage); err != nil { + return err + } + cfg, err := deps.loadConfig() + if err != nil { + return err + } + tok, err := deps.loadToken(cfg) + if err == nil { + if err := requireInvoiceScope(tok, invoiceReadScopes...); err != nil { + return err + } + } + client, err := deps.newClient(cfg) + if err != nil { + return err + } + params := api.PartnerListParams{ + Page: opts.Page, PerPage: opts.PerPage, + Name: opts.Name, Code: opts.Code, NameKana: opts.NameKana, + PartnerPic: opts.PartnerPic, OfficePic: opts.OfficePic, + } + data, err := client.GetPartners(context.Background(), params) + if err != nil { + return err + } + return outputResultTo(deps.stdout, data, opts.Format, opts.Fields, partnersTableRenderer) +} + +func newInvoicePartnersGetCmd(deps cmdDeps) *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Get a single invoice partner by ID", + Args: cobra.MaximumNArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + opts, err := parseInvoicePartnersGetOptions(cmd, args) + if err != nil { + return err + } + if err := runInvoicePartnersGet(deps, opts); err != nil { + exitInvoiceAPIError(err) + } + return nil + }, + } +} + +func runInvoicePartnersGet(deps cmdDeps, opts invoicePartnersGetOptions) error { + if strings.TrimSpace(opts.ID) == "" { + return fmt.Errorf("partner id is required") + } + cfg, err := deps.loadConfig() + if err != nil { + return err + } + tok, err := deps.loadToken(cfg) + if err == nil { + if err := requireInvoiceScope(tok, invoiceReadScopes...); err != nil { + return err + } + } + client, err := deps.newClient(cfg) + if err != nil { + return err + } + data, err := client.GetPartner(context.Background(), opts.ID) + if err != nil { + return err + } + return outputResultTo(deps.stdout, data, opts.Format, opts.Fields, nil) +} + +func partnersTableRenderer(w io.Writer, data []byte) error { + return renderInvoiceTable(w, data, + []string{"ID", "CODE", "NAME", "NAME_KANA"}, + []string{"id", "code", "name", "name_kana"}, + ) +} diff --git a/cmd/invoice_partners_test.go b/cmd/invoice_partners_test.go new file mode 100644 index 0000000..c210c7d --- /dev/null +++ b/cmd/invoice_partners_test.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/beatinaniwa/mf-cli/internal/api" +) + +func TestRunInvoicePartnersList_PassesFilters(t *testing.T) { + client := &fakeInvoiceClient{listResp: []byte(`{"data":[]}`)} + deps, _, _ := invoiceTestDeps(client, nil, readScopes(), "") + opts := invoicePartnersListOptions{ + invoiceCommonOptions: invoiceCommonOptions{Format: "json"}, + Name: "Acme", Code: "A1", PartnerPic: "Tanaka", + } + if err := runInvoicePartnersList(deps, opts); err != nil { + t.Fatalf("err = %v", err) + } + got := client.gotListParams.(api.PartnerListParams) + if got.Name != "Acme" || got.Code != "A1" || got.PartnerPic != "Tanaka" { + t.Errorf("filters not propagated: %+v", got) + } +} + +func TestRunInvoicePartnersGet_BlankID(t *testing.T) { + deps, _, _ := invoiceTestDeps(&fakeInvoiceClient{}, nil, readScopes(), "") + if err := runInvoicePartnersGet(deps, invoicePartnersGetOptions{}); err == nil { + t.Error("expected error for blank id") + } +} + +func TestRunInvoicePartnersGet_HappyPath(t *testing.T) { + client := &fakeInvoiceClient{getResp: []byte(`{"id":"p1"}`)} + deps, stdout, _ := invoiceTestDeps(client, nil, readScopes(), "") + opts := invoicePartnersGetOptions{ + invoiceCommonOptions: invoiceCommonOptions{Format: "json"}, + ID: "p1", + } + if err := runInvoicePartnersGet(deps, opts); err != nil { + t.Fatalf("err = %v", err) + } + if client.gotID != "p1" { + t.Errorf("id = %q", client.gotID) + } + if !strings.Contains(stdout.String(), `"id"`) { + t.Errorf("stdout = %q", stdout.String()) + } +} diff --git a/cmd/invoice_testutil_test.go b/cmd/invoice_testutil_test.go new file mode 100644 index 0000000..7efe3a7 --- /dev/null +++ b/cmd/invoice_testutil_test.go @@ -0,0 +1,134 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "net/url" + "strings" + + "github.com/beatinaniwa/mf-cli/internal/api" + "github.com/beatinaniwa/mf-cli/internal/auth" + "github.com/beatinaniwa/mf-cli/internal/config" + invoicemodel "github.com/beatinaniwa/mf-cli/internal/model/invoice" +) + +// fakeInvoiceClient implements every invoice client interface so it +// can be passed in via cmdDeps.newClient. Each method records the +// inputs and returns canned bytes/errors set by the test. +type fakeInvoiceClient struct { + // observable + gotPartnerID string + gotDepartmentID string + gotID string + gotListParams any + gotCreateReq *invoicemodel.CreateBillingRequest + gotUpdateReq *invoicemodel.UpdateBillingRequest + + // programmable + listResp []byte + getResp []byte + createResp []byte + updateResp []byte + err error + deleteErr error +} + +func (f *fakeInvoiceClient) GetBillings(_ context.Context, p api.BillingListParams) ([]byte, error) { + f.gotListParams = p + return f.listResp, f.err +} +func (f *fakeInvoiceClient) GetBilling(_ context.Context, id string) ([]byte, error) { + f.gotID = id + return f.getResp, f.err +} +func (f *fakeInvoiceClient) CreateBilling(_ context.Context, req *invoicemodel.CreateBillingRequest) ([]byte, error) { + f.gotCreateReq = req + return f.createResp, f.err +} +func (f *fakeInvoiceClient) UpdateBilling(_ context.Context, id string, req *invoicemodel.UpdateBillingRequest) ([]byte, error) { + f.gotID = id + f.gotUpdateReq = req + return f.updateResp, f.err +} +func (f *fakeInvoiceClient) DeleteBilling(_ context.Context, id string) error { + f.gotID = id + return f.deleteErr +} + +func (f *fakeInvoiceClient) GetPartners(_ context.Context, p api.PartnerListParams) ([]byte, error) { + f.gotListParams = p + return f.listResp, f.err +} +func (f *fakeInvoiceClient) GetPartner(_ context.Context, id string) ([]byte, error) { + f.gotID = id + return f.getResp, f.err +} +func (f *fakeInvoiceClient) GetPartnerDepartments(_ context.Context, partnerID string, p api.DepartmentListParams) ([]byte, error) { + f.gotPartnerID = partnerID + f.gotListParams = p + return f.listResp, f.err +} +func (f *fakeInvoiceClient) GetPartnerDepartment(_ context.Context, partnerID, deptID string) ([]byte, error) { + f.gotPartnerID = partnerID + f.gotDepartmentID = deptID + return f.getResp, f.err +} +func (f *fakeInvoiceClient) GetItems(_ context.Context, p api.ItemListParams) ([]byte, error) { + f.gotListParams = p + return f.listResp, f.err +} +func (f *fakeInvoiceClient) GetItem(_ context.Context, id string) ([]byte, error) { + f.gotID = id + return f.getResp, f.err +} + +// fakeDryRunBuilder satisfies DryRunBuilder for command-level dry-run +// tests without needing a real *api.Client (which requires a token +// file). +type fakeDryRunBuilder struct { + gotMethod string + gotPath string + gotBody any +} + +func (f *fakeDryRunBuilder) BuildRequest(method, path string, _ url.Values, body any) (*api.DryRunOutput, error) { + f.gotMethod = method + f.gotPath = path + f.gotBody = body + var raw json.RawMessage + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + raw = b + } + return &api.DryRunOutput{Method: method, URL: "https://invoice.test" + path, Body: raw}, nil +} + +// invoiceTestDeps returns a deps wired with the supplied fakes. +// scopes controls what the fake token reports (nil → "unknown" path). +func invoiceTestDeps(client InvoiceClient, builder DryRunBuilder, scopes []string, stdinBody string) (cmdDeps, *bytes.Buffer, *bytes.Buffer) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + deps := cmdDeps{ + loadConfig: func() (*config.Config, error) { + return &config.Config{InvoiceBaseURL: "https://invoice.test"}, nil + }, + newClient: func(_ *config.Config) (InvoiceClient, error) { return client, nil }, + newDryRunBuilder: func(_ *config.Config) (DryRunBuilder, error) { + if builder == nil { + return &fakeDryRunBuilder{}, nil + } + return builder, nil + }, + loadToken: func(_ *config.Config) (*auth.Token, error) { + return &auth.Token{Scopes: scopes}, nil + }, + stdin: strings.NewReader(stdinBody), + stdout: stdout, + stderr: stderr, + } + return deps, stdout, stderr +} diff --git a/cmd/invoices.go b/cmd/invoices.go new file mode 100644 index 0000000..0b61ef3 --- /dev/null +++ b/cmd/invoices.go @@ -0,0 +1,383 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "net/url" + "strings" + + "github.com/beatinaniwa/mf-cli/internal/api" + invoicemodel "github.com/beatinaniwa/mf-cli/internal/model/invoice" + "github.com/beatinaniwa/mf-cli/internal/validate" + "github.com/spf13/cobra" +) + +// validRangeKeys mirrors the spec enum on `range_key` for `GET /billings`. +var validRangeKeys = []string{"billing_date", "due_date", "sales_date", "created_at", "updated_at"} + +// newInvoicesCmd returns the `mf invoices` subcommand tree. +func newInvoicesCmd(deps cmdDeps) *cobra.Command { + root := &cobra.Command{ + Use: "invoices", + Short: "Manage MoneyForward Invoice API billings", + SilenceUsage: true, + SilenceErrors: true, + } + + root.AddCommand(newInvoicesListCmd(deps)) + root.AddCommand(newInvoicesGetCmd(deps)) + root.AddCommand(newInvoicesCreateCmd(deps)) + root.AddCommand(newInvoicesUpdateCmd(deps)) + root.AddCommand(newInvoicesDeleteCmd(deps)) + return root +} + +func newInvoicesListCmd(deps cmdDeps) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List invoices (billings)", + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + opts, err := parseInvoicesListOptions(cmd, args) + if err != nil { + return err + } + if err := runInvoicesList(deps, opts); err != nil { + exitInvoiceAPIError(err) + } + return nil + }, + } + addInvoicePaginationFlags(cmd) + cmd.Flags().String("range-key", "", "field used for date range filtering ("+strings.Join(validRangeKeys, "|")+")") + cmd.Flags().String("from", "", "range start date") + cmd.Flags().String("to", "", "range end date") + cmd.Flags().String("q", "", "free-text search (overrides other field-specific filters)") + cmd.Flags().String("partner-id", "", "filter by partner ID") + cmd.Flags().String("document-number", "", "filter by billing number (CSV-supported)") + cmd.Flags().String("status", "", "filter by status (CSV-supported)") + cmd.Flags().String("partner-name", "", "filter by partner name (CSV-supported)") + cmd.Flags().String("tags", "", "filter by tag names (CSV-supported)") + return cmd +} + +func runInvoicesList(deps cmdDeps, opts invoicesListOptions) error { + if err := validateInvoiceListOptions(opts); err != nil { + return err + } + + cfg, err := deps.loadConfig() + if err != nil { + return err + } + tok, err := deps.loadToken(cfg) + if err == nil { + if err := requireInvoiceScope(tok, invoiceReadScopes...); err != nil { + return err + } + } + client, err := deps.newClient(cfg) + if err != nil { + return err + } + + params := api.BillingListParams{ + Page: opts.Page, PerPage: opts.PerPage, + RangeKey: opts.RangeKey, From: opts.From, To: opts.To, + Q: opts.Q, PartnerID: opts.PartnerID, + DocumentNumber: opts.DocumentNumber, Status: opts.Status, + PartnerName: opts.PartnerName, Tags: opts.Tags, + } + data, err := client.GetBillings(context.Background(), params) + if err != nil { + return err + } + return outputResultTo(deps.stdout, data, opts.Format, opts.Fields, billingsTableRenderer) +} + +func validateInvoiceListOptions(opts invoicesListOptions) error { + if opts.RangeKey != "" { + if err := validate.ValidateEnum(opts.RangeKey, validRangeKeys); err != nil { + return fmt.Errorf("--range-key: %w", err) + } + } + if opts.From != "" { + if err := validate.ValidateInvoiceDate(opts.From); err != nil { + return fmt.Errorf("--from: %w", err) + } + } + if opts.To != "" { + if err := validate.ValidateInvoiceDate(opts.To); err != nil { + return fmt.Errorf("--to: %w", err) + } + } + if err := validateInvoicePagination(opts.Page, opts.PerPage); err != nil { + return err + } + // Spec note: only document_number / status / partner_name / tags + // are documented as ignored when `q` is supplied. Date range and + // partner_id remain effective alongside q, so we keep them out of + // the conflict list. + if opts.Q != "" { + conflicts := []string{} + if opts.Status != "" { + conflicts = append(conflicts, "--status") + } + if opts.Tags != "" { + conflicts = append(conflicts, "--tags") + } + if opts.DocumentNumber != "" { + conflicts = append(conflicts, "--document-number") + } + if opts.PartnerName != "" { + conflicts = append(conflicts, "--partner-name") + } + if len(conflicts) > 0 { + return fmt.Errorf("--q cannot be combined with %s; the API ignores those filters when q is supplied", + strings.Join(conflicts, ", ")) + } + } + return nil +} + +func newInvoicesGetCmd(deps cmdDeps) *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Get a single invoice by ID", + Args: cobra.MaximumNArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + opts, err := parseInvoicesGetOptions(cmd, args) + if err != nil { + return err + } + if err := runInvoicesGet(deps, opts); err != nil { + exitInvoiceAPIError(err) + } + return nil + }, + } + return cmd +} + +func runInvoicesGet(deps cmdDeps, opts invoicesGetOptions) error { + if strings.TrimSpace(opts.ID) == "" { + return fmt.Errorf("invoice id is required") + } + cfg, err := deps.loadConfig() + if err != nil { + return err + } + tok, err := deps.loadToken(cfg) + if err == nil { + if err := requireInvoiceScope(tok, invoiceReadScopes...); err != nil { + return err + } + } + client, err := deps.newClient(cfg) + if err != nil { + return err + } + data, err := client.GetBilling(context.Background(), opts.ID) + if err != nil { + return err + } + return outputResultTo(deps.stdout, data, opts.Format, opts.Fields, nil) +} + +func newInvoicesCreateCmd(deps cmdDeps) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new invoice draft (POST /api/v3/invoice_template_billings)", + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + opts, err := parseInvoicesCreateOptions(cmd, args) + if err != nil { + return err + } + if err := runInvoicesCreate(deps, opts); err != nil { + exitInvoiceAPIError(err) + } + return nil + }, + } + cmd.Flags().String("json", "", `request body as JSON (or "-" for stdin)`) + cmd.Flags().Bool("dry-run", false, "print the request without sending it") + return cmd +} + +func runInvoicesCreate(deps cmdDeps, opts invoicesCreateOptions) error { + if opts.JSONInput == "" { + return fmt.Errorf("--json is required (use \"-\" to read from stdin)") + } + var req invoicemodel.CreateBillingRequest + if err := readAndUnmarshalJSONUseNumber(deps.stdin, opts.JSONInput, &req); err != nil { + return err + } + if err := validate.ValidateInvoiceCreateRequest(&req); err != nil { + return err + } + cfg, err := deps.loadConfig() + if err != nil { + return err + } + + if opts.DryRun { + builder, err := deps.newDryRunBuilder(cfg) + if err != nil { + return err + } + return handleDryRunTo(deps.stdout, builder, "POST", "/api/v3/invoice_template_billings", &req) + } + + tok, err := deps.loadToken(cfg) + if err == nil { + if err := requireInvoiceScope(tok, invoiceWriteScopes...); err != nil { + return err + } + } + client, err := deps.newClient(cfg) + if err != nil { + return err + } + data, err := client.CreateBilling(context.Background(), &req) + if err != nil { + return err + } + return outputResultTo(deps.stdout, data, opts.Format, opts.Fields, nil) +} + +func newInvoicesUpdateCmd(deps cmdDeps) *cobra.Command { + cmd := &cobra.Command{ + Use: "update ", + Short: "Update an existing invoice (PUT /api/v3/billings/{id})", + Args: cobra.MaximumNArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + opts, err := parseInvoicesUpdateOptions(cmd, args) + if err != nil { + return err + } + if err := runInvoicesUpdate(deps, opts); err != nil { + exitInvoiceAPIError(err) + } + return nil + }, + } + cmd.Flags().String("json", "", `request body as JSON (or "-" for stdin)`) + cmd.Flags().Bool("dry-run", false, "print the request without sending it") + return cmd +} + +func runInvoicesUpdate(deps cmdDeps, opts invoicesUpdateOptions) error { + if strings.TrimSpace(opts.ID) == "" { + return fmt.Errorf("invoice id is required") + } + if opts.JSONInput == "" { + return fmt.Errorf("--json is required (use \"-\" to read from stdin)") + } + var req invoicemodel.UpdateBillingRequest + if err := readAndUnmarshalJSONUseNumber(deps.stdin, opts.JSONInput, &req); err != nil { + return err + } + if err := validate.ValidateInvoiceUpdateRequest(&req); err != nil { + return err + } + + cfg, err := deps.loadConfig() + if err != nil { + return err + } + + if opts.DryRun { + builder, err := deps.newDryRunBuilder(cfg) + if err != nil { + return err + } + return handleDryRunTo(deps.stdout, builder, "PUT", "/api/v3/billings/"+url.PathEscape(opts.ID), &req) + } + + tok, err := deps.loadToken(cfg) + if err == nil { + if err := requireInvoiceScope(tok, invoiceWriteScopes...); err != nil { + return err + } + } + client, err := deps.newClient(cfg) + if err != nil { + return err + } + data, err := client.UpdateBilling(context.Background(), opts.ID, &req) + if err != nil { + return err + } + return outputResultTo(deps.stdout, data, opts.Format, opts.Fields, nil) +} + +func newInvoicesDeleteCmd(deps cmdDeps) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an invoice (DELETE /api/v3/billings/{id})", + Args: cobra.MaximumNArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + opts, err := parseInvoicesDeleteOptions(cmd, args) + if err != nil { + return err + } + if err := runInvoicesDelete(deps, opts); err != nil { + exitInvoiceAPIError(err) + } + return nil + }, + } + cmd.Flags().Bool("dry-run", false, "print the request without sending it") + return cmd +} + +func runInvoicesDelete(deps cmdDeps, opts invoicesDeleteOptions) error { + if strings.TrimSpace(opts.ID) == "" { + return fmt.Errorf("invoice id is required") + } + cfg, err := deps.loadConfig() + if err != nil { + return err + } + + if opts.DryRun { + builder, err := deps.newDryRunBuilder(cfg) + if err != nil { + return err + } + return handleDryRunTo(deps.stdout, builder, "DELETE", "/api/v3/billings/"+url.PathEscape(opts.ID), nil) + } + + tok, err := deps.loadToken(cfg) + if err == nil { + if err := requireInvoiceScope(tok, invoiceWriteScopes...); err != nil { + return err + } + } + client, err := deps.newClient(cfg) + if err != nil { + return err + } + if err := client.DeleteBilling(context.Background(), opts.ID); err != nil { + return err + } + _, _ = fmt.Fprintln(deps.stdout, `{"ok": true}`) + return nil +} + +func billingsTableRenderer(w io.Writer, data []byte) error { + return renderInvoiceTable(w, data, + []string{"ID", "BILLING_NUMBER", "PARTNER_NAME", "BILLING_DATE", "DUE_DATE", "TOTAL_PRICE", "PAYMENT_STATUS"}, + []string{"id", "billing_number", "partner_name", "billing_date", "due_date", "total_price", "payment_status"}, + ) +} diff --git a/cmd/invoices_options.go b/cmd/invoices_options.go new file mode 100644 index 0000000..b31e1e3 --- /dev/null +++ b/cmd/invoices_options.go @@ -0,0 +1,235 @@ +package cmd + +import "github.com/spf13/cobra" + +// invoicesListOptions captures all flags for `mf invoices list`. Page +// and PerPage are pointers so we can distinguish "not provided" (use +// API default) from an explicitly invalid value like 0. +type invoicesListOptions struct { + invoiceCommonOptions + + Page *int + PerPage *int + + RangeKey string + From string + To string + + Q string + PartnerID string + DocumentNumber string + Status string + PartnerName string + Tags string +} + +// invoicesGetOptions captures flags for `mf invoices get `. +type invoicesGetOptions struct { + invoiceCommonOptions + + ID string +} + +// invoicesCreateOptions captures flags for `mf invoices create`. +type invoicesCreateOptions struct { + invoiceCommonOptions + + JSONInput string + DryRun bool +} + +// invoicesUpdateOptions captures flags for `mf invoices update`. +type invoicesUpdateOptions struct { + invoiceCommonOptions + + ID string + JSONInput string + DryRun bool +} + +// invoicesDeleteOptions captures flags for `mf invoices delete`. +type invoicesDeleteOptions struct { + invoiceCommonOptions + + ID string + DryRun bool +} + +// parseInvoicesListOptions extracts the list flags from a cobra +// command. cmd.Flags().Changed is consulted so unset numeric flags +// remain nil rather than zero. +func parseInvoicesListOptions(cmd *cobra.Command, _ []string) (invoicesListOptions, error) { + o := invoicesListOptions{ + invoiceCommonOptions: parseInvoiceCommonOptions(cmd), + } + o.Page, o.PerPage = parseInvoicePaginationFlags(cmd) + o.RangeKey, _ = cmd.Flags().GetString("range-key") + o.From, _ = cmd.Flags().GetString("from") + o.To, _ = cmd.Flags().GetString("to") + o.Q, _ = cmd.Flags().GetString("q") + o.PartnerID, _ = cmd.Flags().GetString("partner-id") + o.DocumentNumber, _ = cmd.Flags().GetString("document-number") + o.Status, _ = cmd.Flags().GetString("status") + o.PartnerName, _ = cmd.Flags().GetString("partner-name") + o.Tags, _ = cmd.Flags().GetString("tags") + return o, nil +} + +func parseInvoicesGetOptions(cmd *cobra.Command, args []string) (invoicesGetOptions, error) { + o := invoicesGetOptions{ + invoiceCommonOptions: parseInvoiceCommonOptions(cmd), + } + if len(args) > 0 { + o.ID = args[0] + } + return o, nil +} + +func parseInvoicesCreateOptions(cmd *cobra.Command, _ []string) (invoicesCreateOptions, error) { + o := invoicesCreateOptions{ + invoiceCommonOptions: parseInvoiceCommonOptions(cmd), + } + o.JSONInput, _ = cmd.Flags().GetString("json") + o.DryRun, _ = cmd.Flags().GetBool("dry-run") + return o, nil +} + +func parseInvoicesUpdateOptions(cmd *cobra.Command, args []string) (invoicesUpdateOptions, error) { + o := invoicesUpdateOptions{ + invoiceCommonOptions: parseInvoiceCommonOptions(cmd), + } + if len(args) > 0 { + o.ID = args[0] + } + o.JSONInput, _ = cmd.Flags().GetString("json") + o.DryRun, _ = cmd.Flags().GetBool("dry-run") + return o, nil +} + +func parseInvoicesDeleteOptions(cmd *cobra.Command, args []string) (invoicesDeleteOptions, error) { + o := invoicesDeleteOptions{ + invoiceCommonOptions: parseInvoiceCommonOptions(cmd), + } + if len(args) > 0 { + o.ID = args[0] + } + o.DryRun, _ = cmd.Flags().GetBool("dry-run") + return o, nil +} + +// invoicePartnersListOptions captures flags for `mf invoice-partners list`. +type invoicePartnersListOptions struct { + invoiceCommonOptions + + Page *int + PerPage *int + + Name string + Code string + NameKana string + PartnerPic string + OfficePic string +} + +type invoicePartnersGetOptions struct { + invoiceCommonOptions + + ID string +} + +func parseInvoicePartnersListOptions(cmd *cobra.Command, _ []string) (invoicePartnersListOptions, error) { + o := invoicePartnersListOptions{ + invoiceCommonOptions: parseInvoiceCommonOptions(cmd), + } + o.Page, o.PerPage = parseInvoicePaginationFlags(cmd) + o.Name, _ = cmd.Flags().GetString("name") + o.Code, _ = cmd.Flags().GetString("code") + o.NameKana, _ = cmd.Flags().GetString("name-kana") + o.PartnerPic, _ = cmd.Flags().GetString("partner-pic") + o.OfficePic, _ = cmd.Flags().GetString("office-pic") + return o, nil +} + +func parseInvoicePartnersGetOptions(cmd *cobra.Command, args []string) (invoicePartnersGetOptions, error) { + o := invoicePartnersGetOptions{ + invoiceCommonOptions: parseInvoiceCommonOptions(cmd), + } + if len(args) > 0 { + o.ID = args[0] + } + return o, nil +} + +// invoiceDepartmentsListOptions captures flags for +// `mf invoice-departments list`. PartnerID is required. +type invoiceDepartmentsListOptions struct { + invoiceCommonOptions + + PartnerID string + Page *int + PerPage *int +} + +type invoiceDepartmentsGetOptions struct { + invoiceCommonOptions + + PartnerID string + DepartmentID string +} + +func parseInvoiceDepartmentsListOptions(cmd *cobra.Command, _ []string) (invoiceDepartmentsListOptions, error) { + o := invoiceDepartmentsListOptions{ + invoiceCommonOptions: parseInvoiceCommonOptions(cmd), + } + o.Page, o.PerPage = parseInvoicePaginationFlags(cmd) + o.PartnerID, _ = cmd.Flags().GetString("partner-id") + return o, nil +} + +func parseInvoiceDepartmentsGetOptions(cmd *cobra.Command, args []string) (invoiceDepartmentsGetOptions, error) { + o := invoiceDepartmentsGetOptions{ + invoiceCommonOptions: parseInvoiceCommonOptions(cmd), + } + o.PartnerID, _ = cmd.Flags().GetString("partner-id") + if len(args) > 0 { + o.DepartmentID = args[0] + } + return o, nil +} + +// invoiceItemsListOptions captures flags for `mf invoice-items list`. +type invoiceItemsListOptions struct { + invoiceCommonOptions + + Page *int + PerPage *int + + Name string + Code string +} + +type invoiceItemsGetOptions struct { + invoiceCommonOptions + + ID string +} + +func parseInvoiceItemsListOptions(cmd *cobra.Command, _ []string) (invoiceItemsListOptions, error) { + o := invoiceItemsListOptions{ + invoiceCommonOptions: parseInvoiceCommonOptions(cmd), + } + o.Page, o.PerPage = parseInvoicePaginationFlags(cmd) + o.Name, _ = cmd.Flags().GetString("name") + o.Code, _ = cmd.Flags().GetString("code") + return o, nil +} + +func parseInvoiceItemsGetOptions(cmd *cobra.Command, args []string) (invoiceItemsGetOptions, error) { + o := invoiceItemsGetOptions{ + invoiceCommonOptions: parseInvoiceCommonOptions(cmd), + } + if len(args) > 0 { + o.ID = args[0] + } + return o, nil +} diff --git a/cmd/invoices_test.go b/cmd/invoices_test.go new file mode 100644 index 0000000..b15ce88 --- /dev/null +++ b/cmd/invoices_test.go @@ -0,0 +1,274 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/beatinaniwa/mf-cli/internal/api" + "github.com/beatinaniwa/mf-cli/internal/auth" +) + +func writeScopes() []string { return []string{auth.ScopeInvoiceWrite} } +func readScopes() []string { return []string{auth.ScopeInvoiceRead} } + +func TestRunInvoicesList_HappyPath(t *testing.T) { + client := &fakeInvoiceClient{listResp: []byte(`{"data":[],"pagination":{}}`)} + deps, stdout, _ := invoiceTestDeps(client, nil, readScopes(), "") + page, perPage := 2, 50 + opts := invoicesListOptions{ + invoiceCommonOptions: invoiceCommonOptions{Format: "json"}, + Page: &page, + PerPage: &perPage, + RangeKey: "billing_date", + From: "2024-04-01", + To: "2024-04-30", + } + if err := runInvoicesList(deps, opts); err != nil { + t.Fatalf("err = %v", err) + } + got := client.gotListParams.(api.BillingListParams) + if got.RangeKey != "billing_date" || got.From != "2024-04-01" || got.To != "2024-04-30" { + t.Errorf("params not propagated: %+v", got) + } + if !strings.Contains(stdout.String(), `"data"`) { + t.Errorf("stdout = %q", stdout.String()) + } +} + +func TestRunInvoicesList_RejectsBadRangeKey(t *testing.T) { + deps, _, _ := invoiceTestDeps(&fakeInvoiceClient{}, nil, readScopes(), "") + opts := invoicesListOptions{ + invoiceCommonOptions: invoiceCommonOptions{Format: "json"}, + RangeKey: "bogus", + } + err := runInvoicesList(deps, opts) + if err == nil || !strings.Contains(err.Error(), "--range-key") { + t.Errorf("expected range-key error, got %v", err) + } +} + +func TestRunInvoicesList_RejectsQWithFilters(t *testing.T) { + deps, _, _ := invoiceTestDeps(&fakeInvoiceClient{}, nil, readScopes(), "") + opts := invoicesListOptions{ + invoiceCommonOptions: invoiceCommonOptions{Format: "json"}, + Q: "needle", + Status: "下書き", + } + err := runInvoicesList(deps, opts) + if err == nil || !strings.Contains(err.Error(), "--q") { + t.Errorf("expected q exclusion error, got %v", err) + } +} + +func TestRunInvoicesList_AllowsQWithDateAndPartner(t *testing.T) { + // Spec only documents document_number / status / partner_name / + // tags as ignored when q is set. --from / --to / --partner-id / + // --range-key remain effective. + client := &fakeInvoiceClient{listResp: []byte(`{"data":[]}`)} + deps, _, _ := invoiceTestDeps(client, nil, readScopes(), "") + opts := invoicesListOptions{ + invoiceCommonOptions: invoiceCommonOptions{Format: "json"}, + Q: "needle", + From: "2024-04-01", + To: "2024-04-30", + PartnerID: "p1", + RangeKey: "billing_date", + } + if err := runInvoicesList(deps, opts); err != nil { + t.Errorf("q + date/partner combo should be allowed, got %v", err) + } +} + +func TestRunInvoicesList_RejectsBadPage(t *testing.T) { + deps, _, _ := invoiceTestDeps(&fakeInvoiceClient{}, nil, readScopes(), "") + zero := 0 + opts := invoicesListOptions{ + invoiceCommonOptions: invoiceCommonOptions{Format: "json"}, + Page: &zero, + } + if err := runInvoicesList(deps, opts); err == nil { + t.Error("expected error for page=0") + } +} + +func TestRunInvoicesGet_BlankIDRejected(t *testing.T) { + deps, _, _ := invoiceTestDeps(&fakeInvoiceClient{}, nil, readScopes(), "") + if err := runInvoicesGet(deps, invoicesGetOptions{}); err == nil { + t.Fatal("expected error for blank id") + } +} + +func TestRunInvoicesGet_HappyPath(t *testing.T) { + client := &fakeInvoiceClient{getResp: []byte(`{"id":"abc"}`)} + deps, stdout, _ := invoiceTestDeps(client, nil, readScopes(), "") + opts := invoicesGetOptions{ + invoiceCommonOptions: invoiceCommonOptions{Format: "json"}, + ID: "abc", + } + if err := runInvoicesGet(deps, opts); err != nil { + t.Fatalf("err = %v", err) + } + if client.gotID != "abc" { + t.Errorf("id not passed: %q", client.gotID) + } + if !strings.Contains(stdout.String(), `"id"`) { + t.Errorf("stdout = %q", stdout.String()) + } +} + +func TestRunInvoicesCreate_DryRunSkipsScopeAndAPI(t *testing.T) { + client := &fakeInvoiceClient{} + builder := &fakeDryRunBuilder{} + // No token scopes — preflight is skipped and dry-run runs first. + deps, stdout, _ := invoiceTestDeps(client, builder, nil, "") + opts := invoicesCreateOptions{ + invoiceCommonOptions: invoiceCommonOptions{Format: "json"}, + JSONInput: `{"department_id":"d","billing_date":"2024-04-01","items":[{"name":"x","price":10000.25,"excise":"ten_percent"}]}`, + DryRun: true, + } + if err := runInvoicesCreate(deps, opts); err != nil { + t.Fatalf("err = %v", err) + } + if builder.gotPath != "/api/v3/invoice_template_billings" { + t.Errorf("builder path = %q", builder.gotPath) + } + if client.gotCreateReq != nil { + t.Error("API client should not be called during dry-run") + } + if !strings.Contains(stdout.String(), `"method": "POST"`) { + t.Errorf("stdout missing dry-run JSON: %s", stdout.String()) + } +} + +func TestRunInvoicesCreate_PreservesNumberPrecision(t *testing.T) { + client := &fakeInvoiceClient{createResp: []byte(`{"id":"new"}`)} + deps, _, _ := invoiceTestDeps(client, nil, writeScopes(), "") + opts := invoicesCreateOptions{ + invoiceCommonOptions: invoiceCommonOptions{Format: "json"}, + JSONInput: `{"department_id":"d","billing_date":"2024-04-01","items":[{"name":"x","price":10000.25,"excise":"ten_percent"}]}`, + } + if err := runInvoicesCreate(deps, opts); err != nil { + t.Fatalf("err = %v", err) + } + if client.gotCreateReq == nil { + t.Fatal("createReq not captured") + } + if got := string(*client.gotCreateReq.Items[0].Price); got != "10000.25" { + t.Errorf("price round-tripped to %q", got) + } +} + +func TestRunInvoicesCreate_MissingJSON(t *testing.T) { + deps, _, _ := invoiceTestDeps(&fakeInvoiceClient{}, nil, writeScopes(), "") + if err := runInvoicesCreate(deps, invoicesCreateOptions{}); err == nil { + t.Error("expected error for missing --json") + } +} + +func TestRunInvoicesCreate_ScopeMissingRejected(t *testing.T) { + deps, _, _ := invoiceTestDeps(&fakeInvoiceClient{createResp: []byte(`{}`)}, nil, []string{"unrelated.scope"}, "") + opts := invoicesCreateOptions{ + invoiceCommonOptions: invoiceCommonOptions{Format: "json"}, + JSONInput: `{"department_id":"d","billing_date":"2024-04-01","items":[{"name":"x","excise":"ten_percent"}]}`, + } + err := runInvoicesCreate(deps, opts) + if err == nil || !strings.Contains(err.Error(), "invoice scope") { + t.Errorf("expected invoice scope error, got %v", err) + } +} + +func TestRunInvoicesCreate_EmptyScopesAllowed(t *testing.T) { + // Empty Scopes is "unknown", preflight skips. + client := &fakeInvoiceClient{createResp: []byte(`{"id":"abc"}`)} + deps, _, _ := invoiceTestDeps(client, nil, []string{}, "") + opts := invoicesCreateOptions{ + invoiceCommonOptions: invoiceCommonOptions{Format: "json"}, + JSONInput: `{"department_id":"d","billing_date":"2024-04-01","items":[{"name":"x","excise":"ten_percent"}]}`, + } + if err := runInvoicesCreate(deps, opts); err != nil { + t.Fatalf("expected to proceed, got %v", err) + } + if client.gotCreateReq == nil { + t.Error("expected API to be called") + } +} + +func TestRunInvoicesCreate_StdinJSON(t *testing.T) { + client := &fakeInvoiceClient{createResp: []byte(`{}`)} + deps, _, _ := invoiceTestDeps(client, nil, writeScopes(), + `{"department_id":"d","billing_date":"2024-04-01","items":[{"name":"x","excise":"ten_percent"}]}`) + opts := invoicesCreateOptions{ + invoiceCommonOptions: invoiceCommonOptions{Format: "json"}, + JSONInput: "-", + } + if err := runInvoicesCreate(deps, opts); err != nil { + t.Fatalf("err = %v", err) + } + if client.gotCreateReq == nil || client.gotCreateReq.DepartmentID != "d" { + t.Errorf("stdin JSON not parsed: %+v", client.gotCreateReq) + } +} + +func TestRunInvoicesUpdate_BlankIDRejected(t *testing.T) { + deps, _, _ := invoiceTestDeps(&fakeInvoiceClient{}, nil, writeScopes(), "") + if err := runInvoicesUpdate(deps, invoicesUpdateOptions{JSONInput: `{}`}); err == nil { + t.Error("expected error for blank id") + } +} + +func TestRunInvoicesUpdate_HappyPath(t *testing.T) { + client := &fakeInvoiceClient{updateResp: []byte(`{"id":"abc"}`)} + deps, stdout, _ := invoiceTestDeps(client, nil, writeScopes(), "") + opts := invoicesUpdateOptions{ + invoiceCommonOptions: invoiceCommonOptions{Format: "json"}, + ID: "abc", + JSONInput: `{"title":"updated"}`, + } + if err := runInvoicesUpdate(deps, opts); err != nil { + t.Fatalf("err = %v", err) + } + if client.gotID != "abc" || client.gotUpdateReq == nil { + t.Errorf("update not invoked: id=%q req=%+v", client.gotID, client.gotUpdateReq) + } + if !strings.Contains(stdout.String(), `"id"`) { + t.Errorf("stdout = %q", stdout.String()) + } +} + +func TestRunInvoicesDelete_NoContentEmitsOk(t *testing.T) { + client := &fakeInvoiceClient{} + deps, stdout, _ := invoiceTestDeps(client, nil, writeScopes(), "") + opts := invoicesDeleteOptions{ + invoiceCommonOptions: invoiceCommonOptions{Format: "json"}, + ID: "abc", + } + if err := runInvoicesDelete(deps, opts); err != nil { + t.Fatalf("err = %v", err) + } + if client.gotID != "abc" { + t.Errorf("delete id = %q", client.gotID) + } + if !strings.Contains(stdout.String(), `"ok": true`) { + t.Errorf("stdout = %q", stdout.String()) + } +} + +func TestRunInvoicesDelete_BlankID(t *testing.T) { + deps, _, _ := invoiceTestDeps(&fakeInvoiceClient{}, nil, writeScopes(), "") + if err := runInvoicesDelete(deps, invoicesDeleteOptions{}); err == nil { + t.Error("expected error for blank id") + } +} + +func TestRunInvoicesCreate_ValidationCatchesBadRequest(t *testing.T) { + deps, _, _ := invoiceTestDeps(&fakeInvoiceClient{}, nil, writeScopes(), "") + opts := invoicesCreateOptions{ + invoiceCommonOptions: invoiceCommonOptions{Format: "json"}, + JSONInput: `{"billing_date":"2024-04-01","items":[{"name":"x","excise":"ten_percent"}]}`, + } + err := runInvoicesCreate(deps, opts) + if err == nil || !strings.Contains(err.Error(), "department_id") { + t.Errorf("expected department_id required error, got %v", err) + } +} + diff --git a/cmd/invoices_wiring_test.go b/cmd/invoices_wiring_test.go new file mode 100644 index 0000000..5cbba8c --- /dev/null +++ b/cmd/invoices_wiring_test.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" +) + +// newTestRoot mirrors the persistent-flag setup of the production +// rootCmd so command constructors can be inspected without touching +// global state. +func newTestRoot() *cobra.Command { + root := &cobra.Command{Use: "mf"} + root.PersistentFlags().StringVar(new(string), "format", "json", "") + root.PersistentFlags().StringVar(new(string), "fields", "", "") + root.PersistentFlags().BoolVar(new(bool), "debug", false, "") + return root +} + +func TestAddInvoiceCommands_RegistersAll(t *testing.T) { + root := newTestRoot() + addInvoiceCommands(root, productionDeps()) + + want := map[string]bool{ + "invoices": false, + "invoice-partners": false, + "invoice-departments": false, + "invoice-items": false, + } + for _, c := range root.Commands() { + if _, ok := want[c.Name()]; ok { + want[c.Name()] = true + } + } + for name, found := range want { + if !found { + t.Errorf("expected command %q to be registered", name) + } + } +} + +func TestNewInvoicesCmd_HasCRUDSubcommands(t *testing.T) { + cmd := newInvoicesCmd(productionDeps()) + want := map[string]bool{"list": false, "get": false, "create": false, "update": false, "delete": false} + for _, c := range cmd.Commands() { + if _, ok := want[c.Name()]; ok { + want[c.Name()] = true + } + } + for name, found := range want { + if !found { + t.Errorf("invoices missing subcommand %q", name) + } + } +} + +func TestNewInvoicesListCmd_FlagsExist(t *testing.T) { + cmd := newInvoicesListCmd(productionDeps()) + for _, name := range []string{"page", "per-page", "range-key", "from", "to", "q", "partner-id", "document-number", "status", "partner-name", "tags"} { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("flag %q not defined", name) + } + } +} + +func TestParseInvoicesListOptions_PageSetWiring(t *testing.T) { + root := newTestRoot() + addInvoiceCommands(root, productionDeps()) + listCmd, _, err := root.Find([]string{"invoices", "list"}) + if err != nil { + t.Fatal(err) + } + if err := listCmd.Flags().Set("page", "0"); err != nil { + t.Fatal(err) + } + if err := listCmd.Flags().Set("per-page", "100"); err != nil { + t.Fatal(err) + } + opts, err := parseInvoicesListOptions(listCmd, nil) + if err != nil { + t.Fatal(err) + } + if opts.Page == nil || *opts.Page != 0 { + t.Errorf("Page = %v, want pointer to 0", opts.Page) + } + if opts.PerPage == nil || *opts.PerPage != 100 { + t.Errorf("PerPage = %v, want pointer to 100", opts.PerPage) + } +} + +func TestParseInvoicesListOptions_UnsetPageRemainsNil(t *testing.T) { + root := newTestRoot() + addInvoiceCommands(root, productionDeps()) + listCmd, _, err := root.Find([]string{"invoices", "list"}) + if err != nil { + t.Fatal(err) + } + opts, err := parseInvoicesListOptions(listCmd, nil) + if err != nil { + t.Fatal(err) + } + if opts.Page != nil { + t.Errorf("Page should be nil when not set, got %v", *opts.Page) + } + if opts.PerPage != nil { + t.Errorf("PerPage should be nil when not set, got %v", *opts.PerPage) + } +} diff --git a/cmd/root.go b/cmd/root.go index cf5d417..bb4a4bf 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,8 +14,17 @@ var ( ) var rootCmd = &cobra.Command{ - Use: "mf", - Short: "CLI for MoneyForward Accounting API", + Use: "mf", + Short: "CLI for MoneyForward Cloud Accounting and Invoice APIs", + Long: `mf is a CLI for the MoneyForward Cloud Accounting and Invoice APIs. + +It is designed to be agent-friendly: outputs JSON by default, supports +--dry-run for mutating operations, and exits with structured JSON +errors on stderr. + +Use 'mf describe' to introspect operations for either API. Use +'mf invoices', 'mf invoice-partners', 'mf invoice-departments', and +'mf invoice-items' for the Invoice API.`, SilenceUsage: true, SilenceErrors: true, } @@ -24,6 +33,8 @@ func init() { rootCmd.PersistentFlags().StringVar(&format, "format", "json", "output format (json|table)") rootCmd.PersistentFlags().StringVar(&fields, "fields", "", "comma-separated list of fields to display") rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "enable debug output") + + addInvoiceCommands(rootCmd, productionDeps()) } func Execute() { diff --git a/go.mod b/go.mod index d19e213..40081af 100644 --- a/go.mod +++ b/go.mod @@ -2,13 +2,17 @@ module github.com/beatinaniwa/mf-cli go 1.26.1 -require github.com/spf13/cobra v1.10.2 +require ( + github.com/gofrs/flock v0.13.0 + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c + github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 +) require ( - github.com/gofrs/flock v0.13.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/kr/text v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/sys v0.37.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index aed286b..fb1b4c2 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,34 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 9a7a49f..d95b695 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -16,9 +16,9 @@ import ( func TestAPIError_Error_WithErrors(t *testing.T) { tests := []struct { - name string - apiErr APIError - wantSub string + name string + apiErr APIError + wantSub string }{ { name: "single error detail", @@ -687,3 +687,39 @@ func TestNewClient_SetsBaseURL(t *testing.T) { t.Error("httpClient should not be nil") } } + +func TestParseErrorResponse_OAuthInsufficientScope(t *testing.T) { + body := []byte(`{"error":"insufficient_scope","error_description":"need invoice scope","error_uri":"https://docs.example.test/oauth"}`) + apiErr := ParseErrorResponse(403, body) + if len(apiErr.Errors) != 1 { + t.Fatalf("expected 1 error detail, got %d", len(apiErr.Errors)) + } + if apiErr.Errors[0].Code != "insufficient_scope" { + t.Errorf("Code = %q", apiErr.Errors[0].Code) + } + if !strings.Contains(apiErr.Errors[0].Message, "need invoice scope") { + t.Errorf("Message = %q", apiErr.Errors[0].Message) + } + if !strings.Contains(apiErr.Errors[0].Message, "https://docs.example.test/oauth") { + t.Errorf("Message missing error_uri: %q", apiErr.Errors[0].Message) + } +} + +func TestParseErrorResponse_OAuthErrorOnly(t *testing.T) { + body := []byte(`{"error":"invalid_scope"}`) + apiErr := ParseErrorResponse(403, body) + if len(apiErr.Errors) != 1 || apiErr.Errors[0].Code != "invalid_scope" { + t.Errorf("Errors = %+v", apiErr.Errors) + } +} + +func TestParseErrorResponse_NonOAuthBodyFallsBackToRaw(t *testing.T) { + body := []byte(`{"unrelated":"value"}`) + apiErr := ParseErrorResponse(500, body) + if len(apiErr.Errors) != 0 { + t.Errorf("Errors should be empty: %+v", apiErr.Errors) + } + if apiErr.RawResponse != string(body) { + t.Errorf("RawResponse = %q", apiErr.RawResponse) + } +} diff --git a/internal/api/client.go b/internal/api/client.go index 72e63e8..45e638f 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -37,7 +37,7 @@ type DryRunOutput struct { Body json.RawMessage `json:"body,omitempty"` } -// NewClient creates a new API client. +// NewClient creates a new API client for the MoneyForward Accounting API. func NewClient(cfg *config.Config, debug bool) *Client { return &Client{ httpClient: &http.Client{Timeout: 30 * time.Second}, @@ -47,6 +47,33 @@ func NewClient(cfg *config.Config, debug bool) *Client { } } +// NewInvoiceClient creates a new API client for the MoneyForward Invoice API. +// +// The base URL is derived from cfg.InvoiceBaseURL after passing through +// config.NormalizeInvoiceBaseURL: a strict origin-only validation that +// trims trailing slashes / "/api/v3" suffixes and rejects malformed +// values. An empty cfg.InvoiceBaseURL falls back to the production +// default (config.DefaultInvoiceBaseURL); any other invalid value is +// returned as a hard error rather than silently rewritten, so a typo in +// a test fixture cannot accidentally route requests to production. +// +// Resource methods on the returned *Client must build their request +// path including the "/api/v3" prefix (the spec uses servers.url for +// the prefix; CLI HTTP paths concatenate it explicitly to mirror the +// accounting client style). +func NewInvoiceClient(cfg *config.Config, debug bool) (*Client, error) { + normalized, err := config.NormalizeInvoiceBaseURL(cfg.InvoiceBaseURL) + if err != nil { + return nil, err + } + return &Client{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + baseURL: normalized, + cfg: cfg, + debug: debug, + }, nil +} + // Do executes an HTTP request against the API with authentication, retry, and error handling. func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body any) ([]byte, error) { token, err := auth.GetValidToken(c.cfg) diff --git a/internal/api/client_test.go b/internal/api/client_test.go new file mode 100644 index 0000000..e186685 --- /dev/null +++ b/internal/api/client_test.go @@ -0,0 +1,92 @@ +package api + +import ( + "strings" + "testing" + + "github.com/beatinaniwa/mf-cli/internal/config" +) + +func TestNewInvoiceClient_EmptyBaseURL_UsesDefault(t *testing.T) { + cfg := &config.Config{} + c, err := NewInvoiceClient(cfg, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.baseURL != config.DefaultInvoiceBaseURL { + t.Errorf("baseURL = %q, want %q", c.baseURL, config.DefaultInvoiceBaseURL) + } +} + +func TestNewInvoiceClient_WhitespaceBaseURL_UsesDefault(t *testing.T) { + cfg := &config.Config{InvoiceBaseURL: " "} + c, err := NewInvoiceClient(cfg, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.baseURL != config.DefaultInvoiceBaseURL { + t.Errorf("baseURL = %q, want %q", c.baseURL, config.DefaultInvoiceBaseURL) + } +} + +func TestNewInvoiceClient_TrailingSlash_Normalized(t *testing.T) { + cfg := &config.Config{InvoiceBaseURL: "https://invoice.example.com/"} + c, err := NewInvoiceClient(cfg, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.baseURL != "https://invoice.example.com" { + t.Errorf("baseURL = %q, want without trailing slash", c.baseURL) + } +} + +func TestNewInvoiceClient_ApiV3Suffix_Stripped(t *testing.T) { + cfg := &config.Config{InvoiceBaseURL: "https://invoice.example.com/api/v3"} + c, err := NewInvoiceClient(cfg, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.baseURL != "https://invoice.example.com" { + t.Errorf("baseURL = %q, want with /api/v3 stripped", c.baseURL) + } +} + +func TestNewInvoiceClient_BadScheme_ReturnsError(t *testing.T) { + cfg := &config.Config{InvoiceBaseURL: "ftp://invoice.example.com"} + if _, err := NewInvoiceClient(cfg, false); err == nil { + t.Fatal("expected error for non-http(s) scheme") + } else if !strings.Contains(err.Error(), "scheme") { + t.Errorf("error should mention scheme, got: %v", err) + } +} + +func TestNewInvoiceClient_RawQuery_ReturnsError(t *testing.T) { + cfg := &config.Config{InvoiceBaseURL: "https://invoice.example.com?x=1"} + if _, err := NewInvoiceClient(cfg, false); err == nil { + t.Fatal("expected error for raw query") + } +} + +func TestNewInvoiceClient_Userinfo_ReturnsError(t *testing.T) { + cfg := &config.Config{InvoiceBaseURL: "https://user:pass@invoice.example.com"} + if _, err := NewInvoiceClient(cfg, false); err == nil { + t.Fatal("expected error for userinfo") + } +} + +func TestNewInvoiceClient_UnexpectedPath_ReturnsError(t *testing.T) { + cfg := &config.Config{InvoiceBaseURL: "https://invoice.example.com/foo"} + if _, err := NewInvoiceClient(cfg, false); err == nil { + t.Fatal("expected error for unexpected path") + } +} + +func TestNewClient_AccountingUnchanged(t *testing.T) { + // The accounting NewClient is intentionally permissive — it must not + // have been broken by adding InvoiceBaseURL handling. + cfg := &config.Config{BaseURL: "https://api-accounting.moneyforward.com"} + c := NewClient(cfg, false) + if c.baseURL != "https://api-accounting.moneyforward.com" { + t.Errorf("baseURL = %q, want unchanged", c.baseURL) + } +} diff --git a/internal/api/errors.go b/internal/api/errors.go index dfa34de..c003465 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -35,6 +35,12 @@ func (e *APIError) Error() string { } // ParseErrorResponse parses an API error response body into an APIError. +// +// It tries the MoneyForward shape (`{"errors":[{"code":..,"message":..}]}`) +// first and falls back to the OAuth-style shape used by 401/403 token +// errors (`{"error":"insufficient_scope","error_description":"...", +// "error_uri":"..."}`). Anything else is preserved verbatim in +// RawResponse. func ParseErrorResponse(statusCode int, body []byte) *APIError { apiErr := &APIError{ StatusCode: statusCode, @@ -46,6 +52,41 @@ func ParseErrorResponse(statusCode int, body []byte) *APIError { return apiErr } + if detail, ok := parseOAuthErrorResponse(body); ok { + apiErr.Errors = []model.ErrorDetail{*detail} + // Preserve the original body so formatInvoiceAPIError can also + // detect the signal via raw substring matching. + apiErr.RawResponse = string(body) + return apiErr + } + apiErr.RawResponse = string(body) return apiErr } + +// parseOAuthErrorResponse decodes an OAuth 2.0 RFC 6749 error body. It +// returns (nil, false) when the body is not parseable JSON or has +// neither an `error` nor `error_description` field. error_uri, when +// present, is appended to the message in parentheses. +func parseOAuthErrorResponse(body []byte) (*model.ErrorDetail, bool) { + var oauth struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + ErrorURI string `json:"error_uri"` + } + if err := json.Unmarshal(body, &oauth); err != nil { + return nil, false + } + if oauth.Error == "" && oauth.ErrorDescription == "" { + return nil, false + } + msg := oauth.ErrorDescription + if oauth.ErrorURI != "" { + if msg != "" { + msg = fmt.Sprintf("%s (see %s)", msg, oauth.ErrorURI) + } else { + msg = fmt.Sprintf("see %s", oauth.ErrorURI) + } + } + return &model.ErrorDetail{Code: oauth.Error, Message: msg}, true +} diff --git a/internal/api/invoice_billings.go b/internal/api/invoice_billings.go new file mode 100644 index 0000000..a79dcc6 --- /dev/null +++ b/internal/api/invoice_billings.go @@ -0,0 +1,126 @@ +package api + +import ( + "context" + "net/url" + "strconv" + + invoicemodel "github.com/beatinaniwa/mf-cli/internal/model/invoice" +) + +// BillingListParams holds query parameters for `GET /api/v3/billings`. +// +// status / tags / document_number / partner_name accept comma-separated +// values per the spec; we therefore keep them as raw strings rather +// than slices to avoid encoding ambiguity. Per the spec, when `q` is +// supplied the field-specific filters are ignored — callers should +// reject that combination at the CLI layer. +type BillingListParams struct { + Page *int + PerPage *int + RangeKey string + From string + To string + Q string + PartnerID string + DocumentNumber string + Status string + PartnerName string + Tags string +} + +// GetBillings retrieves a paginated list of billings. +// GET /api/v3/billings +func (c *Client) GetBillings(ctx context.Context, params BillingListParams) ([]byte, error) { + q := url.Values{} + if params.Page != nil { + q.Set("page", strconv.Itoa(*params.Page)) + } + if params.PerPage != nil { + q.Set("per_page", strconv.Itoa(*params.PerPage)) + } + if params.RangeKey != "" { + q.Set("range_key", params.RangeKey) + } + if params.From != "" { + q.Set("from", params.From) + } + if params.To != "" { + q.Set("to", params.To) + } + if params.Q != "" { + q.Set("q", params.Q) + } + if params.PartnerID != "" { + q.Set("partner_id", params.PartnerID) + } + if params.DocumentNumber != "" { + q.Set("document_number", params.DocumentNumber) + } + if params.Status != "" { + q.Set("status", params.Status) + } + if params.PartnerName != "" { + q.Set("partner_name", params.PartnerName) + } + if params.Tags != "" { + q.Set("tags", params.Tags) + } + return c.Get(ctx, invoicePath("/billings"), q) +} + +// GetBilling retrieves a single billing by ID. +// GET /api/v3/billings/{billing_id} +func (c *Client) GetBilling(ctx context.Context, id string) ([]byte, error) { + if err := requireNonBlankPathID("billing_id", id); err != nil { + return nil, err + } + return c.Get(ctx, invoicePath("/billings/", escapeID(id)), nil) +} + +// CreateBilling creates a new invoice using the invoice-template +// endpoint (this is the documented invoice-system-compliant create). +// POST /api/v3/invoice_template_billings +func (c *Client) CreateBilling(ctx context.Context, req *invoicemodel.CreateBillingRequest) ([]byte, error) { + return c.Post(ctx, invoicePath("/invoice_template_billings"), req) +} + +// UpdateBilling updates an existing billing. +// PUT /api/v3/billings/{billing_id} +func (c *Client) UpdateBilling(ctx context.Context, id string, req *invoicemodel.UpdateBillingRequest) ([]byte, error) { + if err := requireNonBlankPathID("billing_id", id); err != nil { + return nil, err + } + return c.Put(ctx, invoicePath("/billings/", escapeID(id)), req) +} + +// DeleteBilling deletes a billing by ID. +// DELETE /api/v3/billings/{billing_id} +func (c *Client) DeleteBilling(ctx context.Context, id string) error { + if err := requireNonBlankPathID("billing_id", id); err != nil { + return err + } + _, err := c.Delete(ctx, invoicePath("/billings/", escapeID(id)), nil) + return err +} + +// BuildCreateBillingRequest is a dry-run helper. +func (c *Client) BuildCreateBillingRequest(req *invoicemodel.CreateBillingRequest) (*DryRunOutput, error) { + return c.BuildRequest("POST", invoicePath("/invoice_template_billings"), nil, req) +} + +// BuildUpdateBillingRequest is a dry-run helper. +func (c *Client) BuildUpdateBillingRequest(id string, req *invoicemodel.UpdateBillingRequest) (*DryRunOutput, error) { + if err := requireNonBlankPathID("billing_id", id); err != nil { + return nil, err + } + return c.BuildRequest("PUT", invoicePath("/billings/", escapeID(id)), nil, req) +} + +// BuildDeleteBillingRequest is a dry-run helper. +func (c *Client) BuildDeleteBillingRequest(id string) (*DryRunOutput, error) { + if err := requireNonBlankPathID("billing_id", id); err != nil { + return nil, err + } + return c.BuildRequest("DELETE", invoicePath("/billings/", escapeID(id)), nil, nil) +} diff --git a/internal/api/invoice_billings_test.go b/internal/api/invoice_billings_test.go new file mode 100644 index 0000000..27ca90a --- /dev/null +++ b/internal/api/invoice_billings_test.go @@ -0,0 +1,248 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + invoicemodel "github.com/beatinaniwa/mf-cli/internal/model/invoice" +) + +// invoiceTestClient builds a *Client that talks to the test server as +// if it were the production invoice host. It saves a token with both +// invoice scopes so Do()'s auth handshake succeeds. +func invoiceTestClient(t *testing.T, srvURL string) *Client { + t.Helper() + cfg := saveTestToken(t, "valid-token") + cfg.InvoiceBaseURL = srvURL + c, err := NewInvoiceClient(cfg, false) + if err != nil { + t.Fatalf("NewInvoiceClient: %v", err) + } + return c +} + +func TestGetBillings_Defaults(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v3/billings" { + t.Errorf("path = %q, want /api/v3/billings", r.URL.Path) + } + if r.URL.RawQuery != "" { + t.Errorf("RawQuery = %q, want empty", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":[],"pagination":{"total_count":0,"total_pages":0,"per_page":100,"current_page":1}}`) + })) + defer srv.Close() + + c := invoiceTestClient(t, srv.URL) + if _, err := c.GetBillings(context.Background(), BillingListParams{}); err != nil { + t.Fatalf("GetBillings error: %v", err) + } +} + +func TestGetBillings_AllParams(t *testing.T) { + page := 2 + per := 50 + var seen string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":[],"pagination":{"total_count":0,"total_pages":0,"per_page":50,"current_page":2}}`) + })) + defer srv.Close() + + c := invoiceTestClient(t, srv.URL) + _, err := c.GetBillings(context.Background(), BillingListParams{ + Page: &page, PerPage: &per, + RangeKey: "billing_date", From: "2024-01-01", To: "2024-12-31", + Q: "search", PartnerID: "p1", DocumentNumber: "INV-1", + Status: "下書き,ロック中", PartnerName: "Acme", Tags: "a,b", + }) + if err != nil { + t.Fatalf("GetBillings error: %v", err) + } + for _, want := range []string{ + "page=2", "per_page=50", + "range_key=billing_date", "from=2024-01-01", "to=2024-12-31", + "q=search", "partner_id=p1", "document_number=INV-1", + "status=", "partner_name=Acme", "tags=a%2Cb", + } { + if !strings.Contains(seen, want) { + t.Errorf("RawQuery missing %q: %s", want, seen) + } + } +} + +func TestGetBilling_PathContainsEscapedID(t *testing.T) { + var seenURI string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenURI = r.RequestURI + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"id":"x/y","use_invoice_template":true}`) + })) + defer srv.Close() + + c := invoiceTestClient(t, srv.URL) + if _, err := c.GetBilling(context.Background(), "x/y?z#a"); err != nil { + t.Fatalf("GetBilling error: %v", err) + } + // `/`, `?`, `#` must be percent-escaped in the path segment. + if !strings.HasPrefix(seenURI, "/api/v3/billings/x%2Fy%3Fz%23a") { + t.Errorf("path not escaped: %s", seenURI) + } +} + +func TestGetBilling_BlankIDRejected(t *testing.T) { + c := invoiceTestClient(t, "https://placeholder.test") + for _, id := range []string{"", " ", "\t", "\n"} { + _, err := c.GetBilling(context.Background(), id) + if err == nil { + t.Errorf("GetBilling(%q) expected error", id) + } + } +} + +func TestCreateBilling_PathAndBody(t *testing.T) { + var seenPath string + var seenBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.Path + body, _ := io.ReadAll(r.Body) + seenBody = body + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"id":"new"}`) + })) + defer srv.Close() + + c := invoiceTestClient(t, srv.URL) + str := func(s string) *string { return &s } + dec := func(s string) *json.Number { n := json.Number(s); return &n } + req := &invoicemodel.CreateBillingRequest{ + DepartmentID: "dept-1", + BillingDate: "2024-04-01", + Title: str("April"), + Items: []invoicemodel.CreateBillingItem{ + {Name: str("Consult"), Price: dec("10000.25"), Quantity: dec("1"), Excise: str("ten_percent")}, + }, + } + if _, err := c.CreateBilling(context.Background(), req); err != nil { + t.Fatalf("CreateBilling: %v", err) + } + if seenPath != "/api/v3/invoice_template_billings" { + t.Errorf("path = %q", seenPath) + } + s := string(seenBody) + for _, want := range []string{ + `"department_id":"dept-1"`, + `"billing_date":"2024-04-01"`, + `"title":"April"`, + `"price":10000.25`, + } { + if !strings.Contains(s, want) { + t.Errorf("body missing %q: %s", want, s) + } + } +} + +func TestUpdateBilling_PathContainsID(t *testing.T) { + var seenPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("Method = %s, want PUT", r.Method) + } + seenPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"id":"abc"}`) + })) + defer srv.Close() + + c := invoiceTestClient(t, srv.URL) + if _, err := c.UpdateBilling(context.Background(), "abc", &invoicemodel.UpdateBillingRequest{}); err != nil { + t.Fatalf("UpdateBilling: %v", err) + } + if seenPath != "/api/v3/billings/abc" { + t.Errorf("path = %q", seenPath) + } +} + +func TestDeleteBilling_NoContent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("Method = %s, want DELETE", r.Method) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := invoiceTestClient(t, srv.URL) + if err := c.DeleteBilling(context.Background(), "abc"); err != nil { + t.Fatalf("DeleteBilling: %v", err) + } +} + +func TestCreateBilling_429NotRetried(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Header().Set("Retry-After", "30") + w.WriteHeader(http.StatusTooManyRequests) + fmt.Fprint(w, `{"errors":[{"code":"rate_limit","message":"slow down"}]}`) + })) + defer srv.Close() + + c := invoiceTestClient(t, srv.URL) + _, err := c.CreateBilling(context.Background(), &invoicemodel.CreateBillingRequest{ + DepartmentID: "dept-1", + BillingDate: "2024-04-01", + }) + if err == nil { + t.Fatal("expected error on 429") + } + if atomic.LoadInt32(&calls) != 1 { + t.Errorf("server hit %d times, want 1 (no retry on POST)", calls) + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError, got %T (%v)", err, err) + } + if apiErr.StatusCode != http.StatusTooManyRequests { + t.Errorf("StatusCode = %d", apiErr.StatusCode) + } +} + +func TestBuildCreateBillingRequest_DryRun(t *testing.T) { + c := invoiceTestClient(t, "https://invoice.test") + out, err := c.BuildCreateBillingRequest(&invoicemodel.CreateBillingRequest{ + DepartmentID: "dept-1", + BillingDate: "2024-04-01", + }) + if err != nil { + t.Fatalf("BuildCreateBillingRequest: %v", err) + } + if out.Method != "POST" { + t.Errorf("Method = %s", out.Method) + } + if !strings.HasSuffix(out.URL, "/api/v3/invoice_template_billings") { + t.Errorf("URL = %s", out.URL) + } +} + +func TestBuildDeleteBillingRequest_EscapesID(t *testing.T) { + c := invoiceTestClient(t, "https://invoice.test") + out, err := c.BuildDeleteBillingRequest("a/b%c") + if err != nil { + t.Fatalf("BuildDeleteBillingRequest: %v", err) + } + if !strings.Contains(out.URL, "a%2Fb%25c") { + t.Errorf("URL did not escape ID: %s", out.URL) + } +} diff --git a/internal/api/invoice_departments.go b/internal/api/invoice_departments.go new file mode 100644 index 0000000..e99d3d8 --- /dev/null +++ b/internal/api/invoice_departments.go @@ -0,0 +1,43 @@ +package api + +import ( + "context" + "net/url" + "strconv" +) + +// DepartmentListParams holds query parameters for +// `GET /api/v3/partners/{partner_id}/departments`. +type DepartmentListParams struct { + Page *int + PerPage *int +} + +// GetPartnerDepartments retrieves the departments belonging to a +// partner. +// GET /api/v3/partners/{partner_id}/departments +func (c *Client) GetPartnerDepartments(ctx context.Context, partnerID string, params DepartmentListParams) ([]byte, error) { + if err := requireNonBlankPathID("partner_id", partnerID); err != nil { + return nil, err + } + q := url.Values{} + if params.Page != nil { + q.Set("page", strconv.Itoa(*params.Page)) + } + if params.PerPage != nil { + q.Set("per_page", strconv.Itoa(*params.PerPage)) + } + return c.Get(ctx, invoicePath("/partners/", escapeID(partnerID), "/departments"), q) +} + +// GetPartnerDepartment retrieves a single department under a partner. +// GET /api/v3/partners/{partner_id}/departments/{department_id} +func (c *Client) GetPartnerDepartment(ctx context.Context, partnerID, departmentID string) ([]byte, error) { + if err := requireNonBlankPathID("partner_id", partnerID); err != nil { + return nil, err + } + if err := requireNonBlankPathID("department_id", departmentID); err != nil { + return nil, err + } + return c.Get(ctx, invoicePath("/partners/", escapeID(partnerID), "/departments/", escapeID(departmentID)), nil) +} diff --git a/internal/api/invoice_departments_test.go b/internal/api/invoice_departments_test.go new file mode 100644 index 0000000..5986231 --- /dev/null +++ b/internal/api/invoice_departments_test.go @@ -0,0 +1,64 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGetPartnerDepartments_Path(t *testing.T) { + var seenPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":[],"pagination":{"total_count":0,"total_pages":0,"per_page":100,"current_page":1}}`) + })) + defer srv.Close() + + c := invoiceTestClient(t, srv.URL) + if _, err := c.GetPartnerDepartments(context.Background(), "p1", DepartmentListParams{}); err != nil { + t.Fatalf("GetPartnerDepartments: %v", err) + } + if seenPath != "/api/v3/partners/p1/departments" { + t.Errorf("path = %q", seenPath) + } +} + +func TestGetPartnerDepartments_BlankPartnerIDRejected(t *testing.T) { + c := invoiceTestClient(t, "https://invoice.test") + if _, err := c.GetPartnerDepartments(context.Background(), "", DepartmentListParams{}); err == nil { + t.Fatal("expected error for blank partner_id") + } +} + +func TestGetPartnerDepartment_NestedPathEscaped(t *testing.T) { + var seenURI string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenURI = r.RequestURI + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"id":"d"}`) + })) + defer srv.Close() + + c := invoiceTestClient(t, srv.URL) + if _, err := c.GetPartnerDepartment(context.Background(), "p/1", "d?2"); err != nil { + t.Fatalf("GetPartnerDepartment: %v", err) + } + want := "/api/v3/partners/p%2F1/departments/d%3F2" + if !strings.HasPrefix(seenURI, want) { + t.Errorf("URI = %q, want prefix %q", seenURI, want) + } +} + +func TestGetPartnerDepartment_BlankIDsRejected(t *testing.T) { + c := invoiceTestClient(t, "https://invoice.test") + if _, err := c.GetPartnerDepartment(context.Background(), "p1", ""); err == nil { + t.Fatal("expected error for blank department_id") + } + if _, err := c.GetPartnerDepartment(context.Background(), "", "d1"); err == nil { + t.Fatal("expected error for blank partner_id") + } +} diff --git a/internal/api/invoice_items.go b/internal/api/invoice_items.go new file mode 100644 index 0000000..9de5001 --- /dev/null +++ b/internal/api/invoice_items.go @@ -0,0 +1,45 @@ +package api + +import ( + "context" + "net/url" + "strconv" +) + +// ItemListParams holds query parameters for `GET /api/v3/items`. +// +// Both Name and Code accept comma-separated values per the spec. +type ItemListParams struct { + Name string + Code string + Page *int + PerPage *int +} + +// GetItems retrieves a paginated list of items. +// GET /api/v3/items +func (c *Client) GetItems(ctx context.Context, params ItemListParams) ([]byte, error) { + q := url.Values{} + if params.Name != "" { + q.Set("name", params.Name) + } + if params.Code != "" { + q.Set("code", params.Code) + } + if params.Page != nil { + q.Set("page", strconv.Itoa(*params.Page)) + } + if params.PerPage != nil { + q.Set("per_page", strconv.Itoa(*params.PerPage)) + } + return c.Get(ctx, invoicePath("/items"), q) +} + +// GetItem retrieves a single item by ID. +// GET /api/v3/items/{item_id} +func (c *Client) GetItem(ctx context.Context, id string) ([]byte, error) { + if err := requireNonBlankPathID("item_id", id); err != nil { + return nil, err + } + return c.Get(ctx, invoicePath("/items/", escapeID(id)), nil) +} diff --git a/internal/api/invoice_items_test.go b/internal/api/invoice_items_test.go new file mode 100644 index 0000000..33bcb69 --- /dev/null +++ b/internal/api/invoice_items_test.go @@ -0,0 +1,35 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGetItems_Filters(t *testing.T) { + var seen string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":[],"pagination":{"total_count":0,"total_pages":0,"per_page":100,"current_page":1}}`) + })) + defer srv.Close() + + c := invoiceTestClient(t, srv.URL) + if _, err := c.GetItems(context.Background(), ItemListParams{Name: "x", Code: "y"}); err != nil { + t.Fatalf("GetItems: %v", err) + } + if !strings.Contains(seen, "name=x") || !strings.Contains(seen, "code=y") { + t.Errorf("query missing filters: %s", seen) + } +} + +func TestGetItem_BlankIDRejected(t *testing.T) { + c := invoiceTestClient(t, "https://invoice.test") + if _, err := c.GetItem(context.Background(), " "); err == nil { + t.Fatal("expected error for blank id") + } +} diff --git a/internal/api/invoice_partners.go b/internal/api/invoice_partners.go new file mode 100644 index 0000000..8c79985 --- /dev/null +++ b/internal/api/invoice_partners.go @@ -0,0 +1,57 @@ +package api + +import ( + "context" + "net/url" + "strconv" +) + +// PartnerListParams holds query parameters for `GET /api/v3/partners`. +// +// All filter fields accept comma-separated values per the spec. +type PartnerListParams struct { + Name string + Code string + NameKana string + PartnerPic string + OfficePic string + Page *int + PerPage *int +} + +// GetPartners retrieves a paginated list of partners. +// GET /api/v3/partners +func (c *Client) GetPartners(ctx context.Context, params PartnerListParams) ([]byte, error) { + q := url.Values{} + if params.Name != "" { + q.Set("name", params.Name) + } + if params.Code != "" { + q.Set("code", params.Code) + } + if params.NameKana != "" { + q.Set("name_kana", params.NameKana) + } + if params.PartnerPic != "" { + q.Set("partner_pic", params.PartnerPic) + } + if params.OfficePic != "" { + q.Set("office_pic", params.OfficePic) + } + if params.Page != nil { + q.Set("page", strconv.Itoa(*params.Page)) + } + if params.PerPage != nil { + q.Set("per_page", strconv.Itoa(*params.PerPage)) + } + return c.Get(ctx, invoicePath("/partners"), q) +} + +// GetPartner retrieves a single partner by ID. +// GET /api/v3/partners/{partner_id} +func (c *Client) GetPartner(ctx context.Context, id string) ([]byte, error) { + if err := requireNonBlankPathID("partner_id", id); err != nil { + return nil, err + } + return c.Get(ctx, invoicePath("/partners/", escapeID(id)), nil) +} diff --git a/internal/api/invoice_partners_test.go b/internal/api/invoice_partners_test.go new file mode 100644 index 0000000..9803d56 --- /dev/null +++ b/internal/api/invoice_partners_test.go @@ -0,0 +1,64 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGetPartners_Filters(t *testing.T) { + var seen string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v3/partners" { + t.Errorf("path = %q", r.URL.Path) + } + seen = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":[],"pagination":{"total_count":0,"total_pages":0,"per_page":100,"current_page":1}}`) + })) + defer srv.Close() + + page := 1 + c := invoiceTestClient(t, srv.URL) + _, err := c.GetPartners(context.Background(), PartnerListParams{ + Name: "Acme", Code: "A1", NameKana: "アクメ", + PartnerPic: "Tanaka", OfficePic: "Sato", + Page: &page, + }) + if err != nil { + t.Fatalf("GetPartners: %v", err) + } + for _, want := range []string{"name=Acme", "code=A1", "name_kana=", "partner_pic=Tanaka", "office_pic=Sato", "page=1"} { + if !strings.Contains(seen, want) { + t.Errorf("query missing %q: %s", want, seen) + } + } +} + +func TestGetPartner_BlankIDRejected(t *testing.T) { + c := invoiceTestClient(t, "https://invoice.test") + if _, err := c.GetPartner(context.Background(), ""); err == nil { + t.Fatal("expected error for blank id") + } +} + +func TestGetPartner_PathEscaped(t *testing.T) { + var seenURI string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenURI = r.RequestURI + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"id":"p"}`) + })) + defer srv.Close() + + c := invoiceTestClient(t, srv.URL) + if _, err := c.GetPartner(context.Background(), "p/1"); err != nil { + t.Fatalf("GetPartner: %v", err) + } + if !strings.HasPrefix(seenURI, "/api/v3/partners/p%2F1") { + t.Errorf("path not escaped: %s", seenURI) + } +} diff --git a/internal/api/invoice_path.go b/internal/api/invoice_path.go new file mode 100644 index 0000000..6f0e743 --- /dev/null +++ b/internal/api/invoice_path.go @@ -0,0 +1,38 @@ +package api + +import ( + "fmt" + "net/url" + "strings" +) + +// requireNonBlankPathID rejects empty or whitespace-only path segments. +// +// Invoice resources accept opaque string IDs that may legitimately +// contain reserved characters; those are handled by url.PathEscape. +// What we must guard against is silently building a URL like +// `/api/v3/billings/` (empty) or `/api/v3/partners//departments` (blank +// in the middle) because the upstream API returns generic 404s for +// those, which is hard to debug. +func requireNonBlankPathID(name, value string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s is required and must not be blank", name) + } + return nil +} + +// invoicePath builds an Invoice API request path by joining the +// "/api/v3" prefix with the given path segments. Each ID-bearing +// segment must be passed through url.PathEscape *by the caller*; this +// helper only performs concatenation so call sites stay explicit about +// which segments are user input. +func invoicePath(parts ...string) string { + return "/api/v3" + strings.Join(parts, "") +} + +// escapeID is a thin wrapper around url.PathEscape kept colocated with +// requireNonBlankPathID so the two are easy to use together at every +// call site. +func escapeID(id string) string { + return url.PathEscape(id) +} diff --git a/internal/api/testdata/README.md b/internal/api/testdata/README.md new file mode 100644 index 0000000..c197275 --- /dev/null +++ b/internal/api/testdata/README.md @@ -0,0 +1,20 @@ +# Invoice API testdata + +This directory holds JSON fixtures used by the invoice API and command +tests. Fixtures are pinned to the committed `iv_openapi.yaml` (see repo +root for provenance) and are not updated automatically when the live +API changes. + +## Provenance + +| File | Source | +| ----------------------------- | ----------------------------------------------------------- | +| `billings_list.json` | derived from `paths./billings.get.responses.200` example | +| `billing_get.json` | derived from spec example, schema-required fields filled in | +| `create_billing_request.json` | derived from `requestBodies.BillingNewTemplateCreateRequest`| +| `partner_get.json` | example value of `components.examples.Partner` | +| `department_get.json` | example value of `components.examples.Department` | +| `item_get.json` | example value within the items list response | + +If the spec is refreshed, regenerate the affected fixtures and update +this manifest. diff --git a/internal/api/testdata/billing_get.json b/internal/api/testdata/billing_get.json new file mode 100644 index 0000000..2970f22 --- /dev/null +++ b/internal/api/testdata/billing_get.json @@ -0,0 +1,30 @@ +{ + "id": "dLyLiVH5XrNW9OdUw4aYHQ", + "pdf_url": "https://example.test/invoice.pdf", + "operator_id": "op-1", + "department_id": "qwc4iT7ZrywxipJCOqtZQg", + "member_id": "mem-1", + "member_name": "Member", + "partner_id": "95PHKI9_FeSw3coTj673Cg", + "partner_name": "Sample Corp", + "office_name": "My Office", + "office_detail": "Detail", + "title": "April invoice", + "billing_date": "2024-04-01", + "due_date": "2024-04-30", + "is_locked": false, + "items": [ + { + "id": "bi-1", + "name": "Consulting", + "price": "10000", + "quantity": "1", + "excise": "ten_percent" + } + ], + "excise_price": "1000", + "subtotal_price": "10000", + "total_price": "11000", + "use_invoice_template": true, + "created_at": "2024-04-01T09:00:00+09:00" +} diff --git a/internal/api/testdata/billings_list.json b/internal/api/testdata/billings_list.json new file mode 100644 index 0000000..e63715c --- /dev/null +++ b/internal/api/testdata/billings_list.json @@ -0,0 +1,31 @@ +{ + "data": [ + { + "id": "dLyLiVH5XrNW9OdUw4aYHQ", + "department_id": "qwc4iT7ZrywxipJCOqtZQg", + "partner_id": "95PHKI9_FeSw3coTj673Cg", + "partner_name": "Sample Corp", + "billing_date": "2024-04-01", + "due_date": "2024-04-30", + "billing_number": "INV-001", + "title": "April invoice", + "payment_status": "未入金", + "email_status": "未送信", + "posting_status": "未郵送", + "is_locked": false, + "total_price": "11000", + "subtotal_price": "10000", + "excise_price": "1000", + "use_invoice_template": true, + "tag_names": ["urgent"], + "created_at": "2024-04-01T09:00:00+09:00", + "updated_at": "2024-04-01T09:00:00+09:00" + } + ], + "pagination": { + "total_count": 1, + "total_pages": 1, + "per_page": 100, + "current_page": 1 + } +} diff --git a/internal/api/testdata/create_billing_request.json b/internal/api/testdata/create_billing_request.json new file mode 100644 index 0000000..155c369 --- /dev/null +++ b/internal/api/testdata/create_billing_request.json @@ -0,0 +1,13 @@ +{ + "department_id": "qwc4iT7ZrywxipJCOqtZQg", + "billing_date": "2024-04-01", + "title": "April invoice", + "items": [ + { + "name": "Consulting", + "price": 10000.25, + "quantity": 1, + "excise": "ten_percent" + } + ] +} diff --git a/internal/api/testdata/department_get.json b/internal/api/testdata/department_get.json new file mode 100644 index 0000000..b9df6f7 --- /dev/null +++ b/internal/api/testdata/department_get.json @@ -0,0 +1,18 @@ +{ + "id": "qwc4iT7ZrywxipJCOqtZQg", + "zip": "123-4567", + "tel": "1234567", + "prefecture": "山形県", + "address1": "1-2-3", + "address2": "ビル", + "person_name": "Tanaka", + "person_title": "Mr.", + "person_dept": "Sales", + "email": "tanaka@example.test", + "cc_emails": "cc@example.test", + "peppol_id": "0088:0000000000001", + "office_member_name": "Office Member", + "office_member_id": "om-1", + "created_at": "2023-03-20 13:44:52 +0900", + "updated_at": "2023-03-20 13:44:52 +0900" +} diff --git a/internal/api/testdata/item_get.json b/internal/api/testdata/item_get.json new file mode 100644 index 0000000..7d6638a --- /dev/null +++ b/internal/api/testdata/item_get.json @@ -0,0 +1,12 @@ +{ + "id": "t93_uqoFUT_EnX85CJ16XA", + "name": "Consulting", + "code": "C-001", + "detail": "consulting fee", + "unit": "hour", + "price": "10000.25", + "quantity": "1", + "excise": "ten_percent", + "created_at": "2022-07-14 13:14:03 +0900", + "updated_at": "2023-01-27 16:19:56 +0900" +} diff --git a/internal/api/testdata/partner_get.json b/internal/api/testdata/partner_get.json new file mode 100644 index 0000000..ff4d20a --- /dev/null +++ b/internal/api/testdata/partner_get.json @@ -0,0 +1,22 @@ +{ + "id": "95PHKI9_FeSw3coTj673Cg", + "code": "p41uz1dyvw3cj71qrkja", + "name": "Sample Corp", + "name_kana": "サンプル", + "name_suffix": "御中", + "memo": "test partner", + "created_at": "2023-03-20 13:39:28 +0900", + "updated_at": "2023-03-20 13:39:28 +0900", + "departments": [ + { + "id": "qwc4iT7ZrywxipJCOqtZQg", + "person_name": "Tanaka", + "email": "tanaka@example.test" + } + ], + "payment_deadline_setting": { + "due_month": "next_month", + "due_date": 31, + "contingency_day": "keep_as_is" + } +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 23bb83e..012a553 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -861,3 +861,86 @@ func TestStartCallbackServerAsync_StateMismatch(t *testing.T) { t.Errorf("error should mention state mismatch, got: %v", res.Err) } } + +// --------------------------------------------------------------------------- +// scope helper tests +// --------------------------------------------------------------------------- + +func TestAllScopes_IncludesInvoiceReadAndWrite(t *testing.T) { + want := map[string]bool{ + "mfc/invoice/data.read": false, + "mfc/invoice/data.write": false, + } + for _, s := range AllScopes { + if _, ok := want[s]; ok { + want[s] = true + } + } + for s, found := range want { + if !found { + t.Errorf("AllScopes missing %q", s) + } + } +} + +func TestDefaultScopes_DoesNotIncludeInvoice(t *testing.T) { + for _, s := range DefaultScopes { + if strings.HasPrefix(s, "mfc/invoice/") { + t.Errorf("DefaultScopes unexpectedly contains %q", s) + } + } +} + +func TestHasScope_NilToken(t *testing.T) { + if HasScope(nil, "mfc/invoice/data.read") { + t.Error("HasScope(nil, ...) should be false") + } +} + +func TestHasScope_EmptyTokenScopes(t *testing.T) { + tok := &Token{} + if HasScope(tok, "mfc/invoice/data.read") { + t.Error("HasScope on empty Scopes should be false") + } +} + +func TestHasScope_BlankSearchScope(t *testing.T) { + tok := &Token{Scopes: []string{"mfc/invoice/data.read"}} + if HasScope(tok, " ") { + t.Error("HasScope with blank scope should be false") + } +} + +func TestHasScope_WhitespacePadded(t *testing.T) { + tok := &Token{Scopes: []string{" mfc/invoice/data.read "}} + if !HasScope(tok, "mfc/invoice/data.read") { + t.Error("HasScope should trim whitespace in stored scope") + } +} + +func TestHasScope_CaseSensitive(t *testing.T) { + tok := &Token{Scopes: []string{"Mfc/Invoice/Data.Read"}} + if HasScope(tok, "mfc/invoice/data.read") { + t.Error("HasScope should be case-sensitive") + } +} + +func TestHasScope_Duplicates(t *testing.T) { + tok := &Token{Scopes: []string{"mfc/invoice/data.read", "mfc/invoice/data.read"}} + if !HasScope(tok, "mfc/invoice/data.read") { + t.Error("HasScope should still return true for duplicated entries") + } +} + +func TestHasAnyScope(t *testing.T) { + tok := &Token{Scopes: []string{"mfc/invoice/data.write"}} + if !HasAnyScope(tok, "mfc/invoice/data.read", "mfc/invoice/data.write") { + t.Error("HasAnyScope should match any scope present") + } + if HasAnyScope(tok, "mfc/invoice/data.read") { + t.Error("HasAnyScope must not match when no listed scope is present") + } + if HasAnyScope(nil, "x") { + t.Error("HasAnyScope(nil, ...) should be false") + } +} diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index 5264035..52a690e 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -44,6 +44,17 @@ var DefaultScopes = []string{ } // AllScopes contains all available scopes including write permissions. +// +// `mfc/invoice/data.read` and `mfc/invoice/data.write` cover the +// MoneyForward Cloud Invoice API v3. Per the official scope guide, +// `data.write` permits both reference and update access (GET/POST/PUT/ +// DELETE), while `data.read` is read-only. +// +// Note that Token.Scopes records the scopes *requested* during login, +// not necessarily the scopes the OAuth provider granted. Preflight +// checks based on Token.Scopes should therefore be treated as a hint +// rather than authoritative — the API's `insufficient_scope` response +// remains the final source of truth. var AllScopes = []string{ "mfc/accounting/offices.read", "mfc/accounting/accounts.read", @@ -57,6 +68,48 @@ var AllScopes = []string{ "mfc/accounting/voucher.write", "mfc/accounting/trade_partners.write", "mfc/accounting/transaction.write", + "mfc/invoice/data.read", + "mfc/invoice/data.write", +} + +// Invoice scope constants for callers that want symbolic references +// rather than hard-coded strings. +const ( + ScopeInvoiceRead = "mfc/invoice/data.read" + ScopeInvoiceWrite = "mfc/invoice/data.write" +) + +// HasScope reports whether the token's stored scope list contains the +// given scope (case-sensitive after trimming surrounding whitespace). +// A nil token returns false. A token with an empty Scopes slice also +// returns false — callers wanting "unknown" semantics should check +// len(token.Scopes) themselves and skip the preflight. +func HasScope(token *Token, scope string) bool { + if token == nil { + return false + } + want := strings.TrimSpace(scope) + if want == "" { + return false + } + for _, s := range token.Scopes { + if strings.TrimSpace(s) == want { + return true + } + } + return false +} + +// HasAnyScope reports whether the token has at least one of the given +// scopes. Returns false for nil token, empty token.Scopes, or empty +// scopes argument. +func HasAnyScope(token *Token, scopes ...string) bool { + for _, s := range scopes { + if HasScope(token, s) { + return true + } + } + return false } // GenerateCodeVerifier generates a PKCE code verifier (32 random bytes, base64url encoded). diff --git a/internal/config/config.go b/internal/config/config.go index 88fab25..f0cd191 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,29 +3,38 @@ package config import ( "encoding/json" "fmt" + "net/url" "os" "path/filepath" "runtime" "strconv" + "strings" ) const ( - defaultBaseURL = "https://api-accounting.moneyforward.com" - defaultAuthPort = 8089 + defaultBaseURL = "https://api-accounting.moneyforward.com" + // DefaultInvoiceBaseURL is the production origin for the MoneyForward + // Invoice API v3. It is exported so packages outside config (e.g. + // internal/api) can fall back to the same default when callers + // construct a Config manually without going through Load. + DefaultInvoiceBaseURL = "https://invoice.moneyforward.com" + defaultAuthPort = 8089 ) type Config struct { - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` - BaseURL string `json:"base_url"` - ConfigDir string `json:"-"` - AuthPort int `json:"auth_port"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + BaseURL string `json:"base_url"` + InvoiceBaseURL string `json:"invoice_base_url"` + ConfigDir string `json:"-"` + AuthPort int `json:"auth_port"` } func Load() (*Config, error) { cfg := &Config{ - BaseURL: defaultBaseURL, - AuthPort: defaultAuthPort, + BaseURL: defaultBaseURL, + InvoiceBaseURL: DefaultInvoiceBaseURL, + AuthPort: defaultAuthPort, } configDir, err := resolveConfigDir() @@ -58,6 +67,15 @@ func Load() (*Config, error) { } cfg.AuthPort = port } + // MF_INVOICE_BASE_URL overrides config.json. An empty value is treated + // as "no override" so that unsetting the variable through `export + // MF_INVOICE_BASE_URL=""` does not blank out a configured value. + if v := os.Getenv("MF_INVOICE_BASE_URL"); strings.TrimSpace(v) != "" { + cfg.InvoiceBaseURL = v + } + if cfg.InvoiceBaseURL == "" { + cfg.InvoiceBaseURL = DefaultInvoiceBaseURL + } return cfg, nil } @@ -70,6 +88,54 @@ func (c *Config) RequireClientID() error { return nil } +// NormalizeInvoiceBaseURL validates and normalizes a raw Invoice API base URL +// value. It is exported so api.NewInvoiceClient can apply the same strict +// rules to manually constructed Config values without relying on Load. +// +// Rules: +// - Empty / whitespace-only input falls back to DefaultInvoiceBaseURL. +// - Scheme must be http or https. +// - Host must be present. +// - RawQuery, Fragment, and Userinfo must be empty. +// - Trailing slashes are trimmed; a trailing "/api/v3" segment is also +// trimmed (origin-only contract). Any other non-empty path is rejected. +func NormalizeInvoiceBaseURL(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return DefaultInvoiceBaseURL, nil + } + u, err := url.Parse(trimmed) + if err != nil { + return "", fmt.Errorf("invalid invoice_base_url %q: %w", raw, err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return "", fmt.Errorf("invalid invoice_base_url %q: scheme must be http or https", raw) + } + if u.Host == "" { + return "", fmt.Errorf("invalid invoice_base_url %q: host is required", raw) + } + // ForceQuery captures inputs like "https://host/?" where there is + // no query content but the URL is parsed as having a query + // marker; treat that as an invalid query just like a non-empty + // RawQuery so the URL composer cannot accidentally emit "?". + if u.RawQuery != "" || u.ForceQuery { + return "", fmt.Errorf("invalid invoice_base_url %q: query string is not allowed", raw) + } + if u.Fragment != "" { + return "", fmt.Errorf("invalid invoice_base_url %q: fragment is not allowed", raw) + } + if u.User != nil { + return "", fmt.Errorf("invalid invoice_base_url %q: userinfo is not allowed", raw) + } + path := strings.TrimRight(u.Path, "/") + path = strings.TrimSuffix(path, "/api/v3") + if path != "" { + return "", fmt.Errorf("invalid invoice_base_url %q: must be origin only (no path other than optional /api/v3 suffix)", raw) + } + u.Path = "" + return u.String(), nil +} + func resolveConfigDir() (string, error) { if v := os.Getenv("MF_CONFIG_DIR"); v != "" { return v, nil diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cd4d366..1eef114 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -13,6 +13,7 @@ func TestLoad_Defaults(t *testing.T) { t.Setenv("MF_CLIENT_ID", "") t.Setenv("MF_CLIENT_SECRET", "") t.Setenv("MF_AUTH_PORT", "") + t.Setenv("MF_INVOICE_BASE_URL", "") cfg, err := Load() if err != nil { @@ -22,6 +23,9 @@ func TestLoad_Defaults(t *testing.T) { if cfg.BaseURL != "https://api-accounting.moneyforward.com" { t.Errorf("BaseURL = %q, want %q", cfg.BaseURL, "https://api-accounting.moneyforward.com") } + if cfg.InvoiceBaseURL != DefaultInvoiceBaseURL { + t.Errorf("InvoiceBaseURL = %q, want %q", cfg.InvoiceBaseURL, DefaultInvoiceBaseURL) + } if cfg.AuthPort != 8089 { t.Errorf("AuthPort = %d, want %d", cfg.AuthPort, 8089) } @@ -33,6 +37,139 @@ func TestLoad_Defaults(t *testing.T) { } } +func TestLoad_InvoiceBaseURLFromConfigFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("MF_CONFIG_DIR", dir) + t.Setenv("MF_INVOICE_BASE_URL", "") + + cfgData := map[string]any{ + "invoice_base_url": "https://invoice.example.com", + } + data, err := json.Marshal(cfgData) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "config.json"), data, 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg.InvoiceBaseURL != "https://invoice.example.com" { + t.Errorf("InvoiceBaseURL = %q, want %q", cfg.InvoiceBaseURL, "https://invoice.example.com") + } +} + +func TestLoad_MFInvoiceBaseURLOverride(t *testing.T) { + t.Setenv("MF_CONFIG_DIR", t.TempDir()) + t.Setenv("MF_INVOICE_BASE_URL", "https://override.example.com") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg.InvoiceBaseURL != "https://override.example.com" { + t.Errorf("InvoiceBaseURL = %q, want override", cfg.InvoiceBaseURL) + } +} + +func TestLoad_MFInvoiceBaseURLEmpty_PreservesValue(t *testing.T) { + dir := t.TempDir() + t.Setenv("MF_CONFIG_DIR", dir) + t.Setenv("MF_INVOICE_BASE_URL", "") + + cfgData := map[string]any{ + "invoice_base_url": "https://from-file.example.com", + } + data, err := json.Marshal(cfgData) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "config.json"), data, 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg.InvoiceBaseURL != "https://from-file.example.com" { + t.Errorf("InvoiceBaseURL = %q, want config-file value (empty env should be ignored)", cfg.InvoiceBaseURL) + } +} + +func TestLoad_PreservesInvalidInvoiceBaseURLAsIs(t *testing.T) { + // Load() is permissive: NormalizeInvoiceBaseURL is exercised in api.NewInvoiceClient, + // not here. A bad value should round-trip through Load without error so unrelated + // commands keep working. + dir := t.TempDir() + t.Setenv("MF_CONFIG_DIR", dir) + t.Setenv("MF_INVOICE_BASE_URL", "") + + cfgData := map[string]any{ + "invoice_base_url": "not-a-url", + } + data, err := json.Marshal(cfgData) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "config.json"), data, 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg.InvoiceBaseURL != "not-a-url" { + t.Errorf("InvoiceBaseURL = %q, want raw value preserved", cfg.InvoiceBaseURL) + } +} + +func TestNormalizeInvoiceBaseURL(t *testing.T) { + cases := []struct { + name string + raw string + want string + wantErr bool + }{ + {"empty falls back to default", "", DefaultInvoiceBaseURL, false}, + {"whitespace falls back to default", " ", DefaultInvoiceBaseURL, false}, + {"plain origin", "https://invoice.moneyforward.com", "https://invoice.moneyforward.com", false}, + {"trailing slash trimmed", "https://invoice.moneyforward.com/", "https://invoice.moneyforward.com", false}, + {"api/v3 suffix trimmed", "https://invoice.moneyforward.com/api/v3", "https://invoice.moneyforward.com", false}, + {"api/v3 with trailing slash", "https://invoice.moneyforward.com/api/v3/", "https://invoice.moneyforward.com", false}, + {"surrounding whitespace", " https://invoice.moneyforward.com ", "https://invoice.moneyforward.com", false}, + {"http allowed", "http://localhost:1234", "http://localhost:1234", false}, + {"bad scheme", "ftp://invoice.example.com", "", true}, + {"missing host", "https://", "", true}, + {"raw query rejected", "https://invoice.example.com?x=1", "", true}, + {"force query (bare ?) rejected", "https://invoice.example.com/?", "", true}, + {"fragment rejected", "https://invoice.example.com#frag", "", true}, + {"userinfo rejected", "https://user:pass@invoice.example.com", "", true}, + {"unexpected path rejected", "https://invoice.example.com/foo", "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := NormalizeInvoiceBaseURL(tc.raw) + if tc.wantErr { + if err == nil { + t.Errorf("NormalizeInvoiceBaseURL(%q) error = nil, want error", tc.raw) + } + return + } + if err != nil { + t.Fatalf("NormalizeInvoiceBaseURL(%q) returned error: %v", tc.raw, err) + } + if got != tc.want { + t.Errorf("NormalizeInvoiceBaseURL(%q) = %q, want %q", tc.raw, got, tc.want) + } + }) + } +} + func TestLoad_FromConfigFile(t *testing.T) { dir := t.TempDir() t.Setenv("MF_CONFIG_DIR", dir) diff --git a/internal/model/invoice/billing.go b/internal/model/invoice/billing.go new file mode 100644 index 0000000..02aa9f8 --- /dev/null +++ b/internal/model/invoice/billing.go @@ -0,0 +1,158 @@ +package invoice + +import "encoding/json" + +// Billing mirrors `components.schemas.Billing` from iv_openapi.yaml. +// Numeric fields like Price and TotalPrice are returned as strings by +// the Invoice API to preserve decimal precision; we keep them typed as +// string on the read side. +type Billing struct { + ID string `json:"id"` + PdfURL string `json:"pdf_url,omitempty"` + OperatorID string `json:"operator_id,omitempty"` + DepartmentID string `json:"department_id,omitempty"` + MemberID string `json:"member_id,omitempty"` + MemberName string `json:"member_name,omitempty"` + PartnerID string `json:"partner_id,omitempty"` + PartnerName string `json:"partner_name,omitempty"` + OfficeID string `json:"office_id,omitempty"` + OfficeName string `json:"office_name,omitempty"` + OfficeDetail string `json:"office_detail,omitempty"` + Title string `json:"title,omitempty"` + Memo string `json:"memo,omitempty"` + PaymentCondition string `json:"payment_condition,omitempty"` + BillingDate string `json:"billing_date,omitempty"` + DueDate string `json:"due_date,omitempty"` + SalesDate string `json:"sales_date,omitempty"` + BillingNumber string `json:"billing_number,omitempty"` + Note string `json:"note,omitempty"` + DocumentName string `json:"document_name,omitempty"` + PaymentStatus string `json:"payment_status,omitempty"` + EmailStatus string `json:"email_status,omitempty"` + PostingStatus string `json:"posting_status,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + IsDownloaded bool `json:"is_downloaded,omitempty"` + IsLocked bool `json:"is_locked,omitempty"` + DeductPrice string `json:"deduct_price,omitempty"` + TagNames []string `json:"tag_names,omitempty"` + Items []BillingItem `json:"items,omitempty"` + ExcisePrice string `json:"excise_price,omitempty"` + ExcisePriceOfUntaxable string `json:"excise_price_of_untaxable,omitempty"` + ExcisePriceOfNonTaxable string `json:"excise_price_of_non_taxable,omitempty"` + ExcisePriceOfTaxExemption string `json:"excise_price_of_tax_exemption,omitempty"` + ExcisePriceOfFivePercent string `json:"excise_price_of_five_percent,omitempty"` + ExcisePriceOfEightPercent string `json:"excise_price_of_eight_percent,omitempty"` + ExcisePriceOfEightPercentAsReducedTaxRate string `json:"excise_price_of_eight_percent_as_reduced_tax_rate,omitempty"` + ExcisePriceOfTenPercent string `json:"excise_price_of_ten_percent,omitempty"` + SubtotalPrice string `json:"subtotal_price,omitempty"` + SubtotalOfUntaxableExcise string `json:"subtotal_of_untaxable_excise,omitempty"` + SubtotalOfNonTaxableExcise string `json:"subtotal_of_non_taxable_excise,omitempty"` + SubtotalOfTaxExemptionExcise string `json:"subtotal_of_tax_exemption_excise,omitempty"` + SubtotalOfFivePercentExcise string `json:"subtotal_of_five_percent_excise,omitempty"` + SubtotalOfEightPercentExcise string `json:"subtotal_of_eight_percent_excise,omitempty"` + SubtotalOfEightPercentAsReducedTaxRateExcise string `json:"subtotal_of_eight_percent_as_reduced_tax_rate_excise,omitempty"` + SubtotalOfTenPercentExcise string `json:"subtotal_of_ten_percent_excise,omitempty"` + SubtotalWithTaxOfUntaxableExcise string `json:"subtotal_with_tax_of_untaxable_excise,omitempty"` + SubtotalWithTaxOfNonTaxableExcise string `json:"subtotal_with_tax_of_non_taxable_excise,omitempty"` + SubtotalWithTaxOfTaxExemptionExcise string `json:"subtotal_with_tax_of_tax_exemption_excise,omitempty"` + SubtotalWithTaxOfFivePercentExcise string `json:"subtotal_with_tax_of_five_percent_excise,omitempty"` + SubtotalWithTaxOfEightPercentExcise string `json:"subtotal_with_tax_of_eight_percent_excise,omitempty"` + SubtotalWithTaxOfEightPercentAsReducedTaxRateExcise string `json:"subtotal_with_tax_of_eight_percent_as_reduced_tax_rate_excise,omitempty"` + SubtotalWithTaxOfTenPercentExcise string `json:"subtotal_with_tax_of_ten_percent_excise,omitempty"` + TotalPrice string `json:"total_price,omitempty"` + RegistrationCode string `json:"registration_code,omitempty"` + UseInvoiceTemplate bool `json:"use_invoice_template,omitempty"` +} + +// BillingItem mirrors `components.schemas.BillingItem`. Read-side +// numeric fields (Price, Quantity) are strings per the Invoice API +// schema, matching Billing. +type BillingItem struct { + ID string `json:"id,omitempty"` + ItemID string `json:"item_id,omitempty"` + Name string `json:"name,omitempty"` + Code string `json:"code,omitempty"` + Detail string `json:"detail,omitempty"` + Unit string `json:"unit,omitempty"` + Price string `json:"price,omitempty"` + Quantity string `json:"quantity,omitempty"` + IsDeductWithholdingTax *bool `json:"is_deduct_withholding_tax,omitempty"` + Excise string `json:"excise,omitempty"` + DeliveryNumber string `json:"delivery_number,omitempty"` + DeliveryDate string `json:"delivery_date,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// ListBillingsResponse wraps the paginated `GET /billings` response. +type ListBillingsResponse struct { + Data []Billing `json:"data"` + Pagination Pagination `json:"pagination"` +} + +// CreateBillingRequest matches the body of +// `POST /api/v3/invoice_template_billings`. department_id and +// billing_date are required by the spec; everything else is optional +// and uses pointer fields so unset values are omitted from the JSON. +// +// TagNames is `*[]string` rather than `[]string` so callers can +// distinguish "tag_names not supplied" (nil → omit from payload) from +// "tag_names explicitly empty" (`*[]string{}` → emit `[]`). With a +// plain slice, `omitempty` would erase the empty case. +type CreateBillingRequest struct { + DepartmentID string `json:"department_id"` + BillingDate string `json:"billing_date"` + Title *string `json:"title,omitempty"` + Memo *string `json:"memo,omitempty"` + PaymentCondition *string `json:"payment_condition,omitempty"` + DueDate *string `json:"due_date,omitempty"` + SalesDate *string `json:"sales_date,omitempty"` + BillingNumber *string `json:"billing_number,omitempty"` + Note *string `json:"note,omitempty"` + DocumentName *string `json:"document_name,omitempty"` + TagNames *[]string `json:"tag_names,omitempty"` + Items []CreateBillingItem `json:"items,omitempty"` +} + +// CreateBillingItem matches an entry in `items[]` of the +// invoice_template_billings request. Numeric fields are typed as +// json.Number so callers can preserve the exact lexical form of values +// like "0.10" or "10000.25" without float64 round-tripping. +type CreateBillingItem struct { + ItemID *string `json:"item_id,omitempty"` + Name *string `json:"name,omitempty"` + Detail *string `json:"detail,omitempty"` + Unit *string `json:"unit,omitempty"` + Price *json.Number `json:"price,omitempty"` + Quantity *json.Number `json:"quantity,omitempty"` + IsDeductWithholdingTax *bool `json:"is_deduct_withholding_tax,omitempty"` + Excise *string `json:"excise,omitempty"` + DeliveryNumber *string `json:"delivery_number,omitempty"` + DeliveryDate *string `json:"delivery_date,omitempty"` +} + +// UpdateBillingRequest mirrors the body of `PUT /api/v3/billings/{id}`. +// All top-level fields are optional; the spec accepts a partial update. +// +// Note: the spec does NOT accept an `items` field for update. Line +// items are managed via the dedicated +// `POST/DELETE /api/v3/billings/{id}/items` endpoints (out of scope +// for the current PR). Including `items` here would produce a 400 at +// the API. +type UpdateBillingRequest struct { + DepartmentID *string `json:"department_id,omitempty"` + BillingDate *string `json:"billing_date,omitempty"` + Title *string `json:"title,omitempty"` + Memo *string `json:"memo,omitempty"` + PaymentCondition *string `json:"payment_condition,omitempty"` + DueDate *string `json:"due_date,omitempty"` + SalesDate *string `json:"sales_date,omitempty"` + BillingNumber *string `json:"billing_number,omitempty"` + Note *string `json:"note,omitempty"` + DocumentName *string `json:"document_name,omitempty"` + // TagNames is *[]string for the same reason as + // CreateBillingRequest.TagNames: callers can distinguish + // "leave tags untouched" (nil) from "clear tags" (empty slice). + TagNames *[]string `json:"tag_names,omitempty"` +} diff --git a/internal/model/invoice/billing_test.go b/internal/model/invoice/billing_test.go new file mode 100644 index 0000000..7330070 --- /dev/null +++ b/internal/model/invoice/billing_test.go @@ -0,0 +1,181 @@ +package invoice + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// fixturePath returns the absolute path to a fixture file under +// internal/api/testdata, which is shared between the api and model packages. +func fixturePath(t *testing.T, name string) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + // internal/model/invoice → internal/api/testdata + return filepath.Join(wd, "..", "..", "api", "testdata", name) +} + +func TestBilling_UnmarshalListFixture(t *testing.T) { + data, err := os.ReadFile(fixturePath(t, "billings_list.json")) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + var resp ListBillingsResponse + if err := json.Unmarshal(data, &resp); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if len(resp.Data) != 1 { + t.Fatalf("Data length = %d, want 1", len(resp.Data)) + } + b := resp.Data[0] + if b.ID != "dLyLiVH5XrNW9OdUw4aYHQ" { + t.Errorf("ID = %q", b.ID) + } + if b.PaymentStatus != "未入金" { + t.Errorf("PaymentStatus = %q", b.PaymentStatus) + } + if b.PostingStatus != "未郵送" { + t.Errorf("PostingStatus = %q", b.PostingStatus) + } + if b.TotalPrice != "11000" { + t.Errorf("TotalPrice = %q", b.TotalPrice) + } + if !b.UseInvoiceTemplate { + t.Error("UseInvoiceTemplate should be true") + } + if resp.Pagination.PerPage != 100 { + t.Errorf("Pagination.PerPage = %d", resp.Pagination.PerPage) + } +} + +func TestBilling_UnmarshalGetFixture(t *testing.T) { + data, err := os.ReadFile(fixturePath(t, "billing_get.json")) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + var b Billing + if err := json.Unmarshal(data, &b); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if len(b.Items) != 1 { + t.Fatalf("Items length = %d", len(b.Items)) + } + if b.Items[0].Price != "10000" { + t.Errorf("Items[0].Price = %q", b.Items[0].Price) + } + if b.Items[0].Excise != "ten_percent" { + t.Errorf("Items[0].Excise = %q", b.Items[0].Excise) + } +} + +func TestCreateBillingRequest_MarshalPreservesNumberLiterals(t *testing.T) { + // Build a request whose price uses a literal that float64 would + // rewrite (e.g. 10000.25). json.Number must keep it verbatim. + dec := func(s string) *json.Number { n := json.Number(s); return &n } + str := func(s string) *string { return &s } + req := CreateBillingRequest{ + DepartmentID: "dept-1", + BillingDate: "2024-04-01", + Title: str("April invoice"), + Items: []CreateBillingItem{ + { + Name: str("Consulting"), + Price: dec("10000.25"), + Quantity: dec("0.10"), + Excise: str("ten_percent"), + }, + }, + } + out, err := json.Marshal(req) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + s := string(out) + for _, want := range []string{ + `"price":10000.25`, + `"quantity":0.10`, + `"department_id":"dept-1"`, + `"billing_date":"2024-04-01"`, + } { + if !strings.Contains(s, want) { + t.Errorf("output missing %q: %s", want, s) + } + } + // Optional pointer fields that were not set must be omitted. + for _, mustNotContain := range []string{"memo", "due_date", "sales_date"} { + if strings.Contains(s, mustNotContain) { + t.Errorf("output unexpectedly contains optional field %q: %s", mustNotContain, s) + } + } +} + +func TestCreateBillingRequest_TagNamesEmptySliceEmitted(t *testing.T) { + tags := []string{} + req := CreateBillingRequest{ + DepartmentID: "d", + BillingDate: "2024-04-01", + TagNames: &tags, + } + out, err := json.Marshal(req) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out), `"tag_names":[]`) { + t.Errorf("expected explicit empty tag_names array, got: %s", out) + } +} + +func TestCreateBillingRequest_TagNamesNilOmitted(t *testing.T) { + req := CreateBillingRequest{ + DepartmentID: "d", + BillingDate: "2024-04-01", + } + out, err := json.Marshal(req) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(out), "tag_names") { + t.Errorf("nil TagNames should be omitted, got: %s", out) + } +} + +func TestUpdateBillingRequest_OmitsItems(t *testing.T) { + // PUT /billings/{id} does not accept an `items` field. The marshal + // shape must therefore not contain it, even if a future change + // accidentally adds an Items field to the struct. + req := UpdateBillingRequest{} + out, err := json.Marshal(req) + if err != nil { + t.Fatal(err) + } + if got := string(out); got != `{}` { + t.Errorf("empty UpdateBillingRequest = %q, want {}", got) + } +} + +func TestCreateBillingRequest_FixtureMatchesModel(t *testing.T) { + data, err := os.ReadFile(fixturePath(t, "create_billing_request.json")) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + dec := json.NewDecoder(strings.NewReader(string(data))) + dec.UseNumber() + var req CreateBillingRequest + if err := dec.Decode(&req); err != nil { + t.Fatalf("Decode: %v", err) + } + if req.DepartmentID == "" || req.BillingDate == "" { + t.Errorf("required fields missing: %+v", req) + } + if len(req.Items) != 1 { + t.Fatalf("Items length = %d", len(req.Items)) + } + if req.Items[0].Price == nil || string(*req.Items[0].Price) != "10000.25" { + t.Errorf("price not preserved: %+v", req.Items[0].Price) + } +} diff --git a/internal/model/invoice/department.go b/internal/model/invoice/department.go new file mode 100644 index 0000000..7bd5191 --- /dev/null +++ b/internal/model/invoice/department.go @@ -0,0 +1,29 @@ +package invoice + +// Department mirrors `components.schemas.Department`. Only `id` is +// guaranteed by the spec; everything else is optional. +type Department struct { + ID string `json:"id"` + Zip string `json:"zip,omitempty"` + Tel string `json:"tel,omitempty"` + Prefecture string `json:"prefecture,omitempty"` + Address1 string `json:"address1,omitempty"` + Address2 string `json:"address2,omitempty"` + PersonName string `json:"person_name,omitempty"` + PersonTitle string `json:"person_title,omitempty"` + PersonDept string `json:"person_dept,omitempty"` + Email string `json:"email,omitempty"` + CcEmails string `json:"cc_emails,omitempty"` + PeppolID string `json:"peppol_id,omitempty"` + OfficeMemberID string `json:"office_member_id,omitempty"` + OfficeMemberName string `json:"office_member_name,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// ListDepartmentsResponse wraps the paginated +// `GET /partners/{partner_id}/departments` response. +type ListDepartmentsResponse struct { + Data []Department `json:"data"` + Pagination Pagination `json:"pagination"` +} diff --git a/internal/model/invoice/department_test.go b/internal/model/invoice/department_test.go new file mode 100644 index 0000000..a3f1f73 --- /dev/null +++ b/internal/model/invoice/department_test.go @@ -0,0 +1,27 @@ +package invoice + +import ( + "encoding/json" + "os" + "testing" +) + +func TestDepartment_UnmarshalFixture(t *testing.T) { + data, err := os.ReadFile(fixturePath(t, "department_get.json")) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + var d Department + if err := json.Unmarshal(data, &d); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if d.ID == "" { + t.Error("ID empty") + } + if d.Email != "tanaka@example.test" { + t.Errorf("Email = %q", d.Email) + } + if d.PeppolID != "0088:0000000000001" { + t.Errorf("PeppolID = %q", d.PeppolID) + } +} diff --git a/internal/model/invoice/item.go b/internal/model/invoice/item.go new file mode 100644 index 0000000..6181da7 --- /dev/null +++ b/internal/model/invoice/item.go @@ -0,0 +1,23 @@ +package invoice + +// Item mirrors `components.schemas.Item`. price and quantity are +// strings on the API response (decimal-preserving). +type Item struct { + ID string `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Detail string `json:"detail,omitempty"` + Unit string `json:"unit,omitempty"` + Price string `json:"price,omitempty"` + Quantity string `json:"quantity,omitempty"` + IsDeductWithholdingTax *bool `json:"is_deduct_withholding_tax,omitempty"` + Excise string `json:"excise,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// ListItemsResponse wraps the paginated `GET /items` response. +type ListItemsResponse struct { + Data []Item `json:"data"` + Pagination Pagination `json:"pagination"` +} diff --git a/internal/model/invoice/item_test.go b/internal/model/invoice/item_test.go new file mode 100644 index 0000000..956746e --- /dev/null +++ b/internal/model/invoice/item_test.go @@ -0,0 +1,24 @@ +package invoice + +import ( + "encoding/json" + "os" + "testing" +) + +func TestItem_UnmarshalFixturePreservesStringPrice(t *testing.T) { + data, err := os.ReadFile(fixturePath(t, "item_get.json")) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + var it Item + if err := json.Unmarshal(data, &it); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if it.Price != "10000.25" { + t.Errorf("Price = %q (want exact decimal)", it.Price) + } + if it.Excise != "ten_percent" { + t.Errorf("Excise = %q", it.Excise) + } +} diff --git a/internal/model/invoice/pagination.go b/internal/model/invoice/pagination.go new file mode 100644 index 0000000..36dfa4e --- /dev/null +++ b/internal/model/invoice/pagination.go @@ -0,0 +1,18 @@ +// Package invoice contains request and response models for the +// MoneyForward Cloud Invoice API v3. +// +// Field names and JSON tags are derived from the committed iv_openapi.yaml +// (see repo root). The package is kept separate from the accounting +// model package because resources of the same name (Partner, Department, +// Item) carry different shapes between the two APIs. +package invoice + +// Pagination mirrors `components.schemas.PaginationData` from +// iv_openapi.yaml. The Invoice API uses this pagination wrapper for +// every list response. +type Pagination struct { + TotalCount int `json:"total_count"` + TotalPages int `json:"total_pages"` + PerPage int `json:"per_page"` + CurrentPage int `json:"current_page"` +} diff --git a/internal/model/invoice/partner.go b/internal/model/invoice/partner.go new file mode 100644 index 0000000..7eafdb2 --- /dev/null +++ b/internal/model/invoice/partner.go @@ -0,0 +1,29 @@ +package invoice + +// Partner mirrors `components.schemas.Partner`. payment_deadline_setting +// is nullable in the spec so it is held as a pointer. +type Partner struct { + ID string `json:"id"` + Code string `json:"code,omitempty"` + Name string `json:"name"` + NameKana string `json:"name_kana,omitempty"` + NameSuffix string `json:"name_suffix,omitempty"` + Memo string `json:"memo,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Departments []Department `json:"departments"` + PaymentDeadlineSetting *PaymentDeadlineSetting `json:"payment_deadline_setting,omitempty"` +} + +// PaymentDeadlineSetting mirrors `components.schemas.PaymentDeadlineSetting`. +type PaymentDeadlineSetting struct { + DueMonth string `json:"due_month"` + DueDate int `json:"due_date"` + ContingencyDay string `json:"contingency_day"` +} + +// ListPartnersResponse wraps the paginated `GET /partners` response. +type ListPartnersResponse struct { + Data []Partner `json:"data"` + Pagination Pagination `json:"pagination"` +} diff --git a/internal/model/invoice/partner_test.go b/internal/model/invoice/partner_test.go new file mode 100644 index 0000000..f7cb3db --- /dev/null +++ b/internal/model/invoice/partner_test.go @@ -0,0 +1,36 @@ +package invoice + +import ( + "encoding/json" + "os" + "testing" +) + +func TestPartner_UnmarshalFixtureWithPaymentDeadline(t *testing.T) { + data, err := os.ReadFile(fixturePath(t, "partner_get.json")) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + var p Partner + if err := json.Unmarshal(data, &p); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if p.Name != "Sample Corp" { + t.Errorf("Name = %q", p.Name) + } + if len(p.Departments) != 1 { + t.Fatalf("Departments length = %d", len(p.Departments)) + } + if p.PaymentDeadlineSetting == nil { + t.Fatal("PaymentDeadlineSetting is nil") + } + if p.PaymentDeadlineSetting.DueMonth != "next_month" { + t.Errorf("DueMonth = %q", p.PaymentDeadlineSetting.DueMonth) + } + if p.PaymentDeadlineSetting.DueDate != 31 { + t.Errorf("DueDate = %d", p.PaymentDeadlineSetting.DueDate) + } + if p.PaymentDeadlineSetting.ContingencyDay != "keep_as_is" { + t.Errorf("ContingencyDay = %q", p.PaymentDeadlineSetting.ContingencyDay) + } +} diff --git a/internal/schema/schema.go b/internal/schema/schema.go index b310cdd..7d2af03 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -12,8 +12,14 @@ import ( var htmlTagRegex = regexp.MustCompile(`<[^>]*>`) // Schema holds the parsed OpenAPI spec and provides methods to query it. +// +// A Schema instance is bound to one of the supported APIs (accounting or +// invoice) via its pathMap, so the same parser can describe both APIs +// without state leaking between them. Use NewAccounting or NewInvoice to +// pick the API; New is preserved as an alias for the accounting variant. type Schema struct { - spec map[string]any + spec map[string]any + pathMap map[string][]string } // ResourceInfo describes a CLI resource and its available API operations. @@ -24,14 +30,15 @@ type ResourceInfo struct { // OperationInfo describes a single API operation. type OperationInfo struct { - Name string `json:"name"` - Method string `json:"method"` - Path string `json:"path"` - Summary string `json:"summary,omitempty"` - Description string `json:"description,omitempty"` - Parameters []ParameterInfo `json:"parameters,omitempty"` - HasRequestBody bool `json:"has_request_body,omitempty"` - RequiredScope string `json:"required_scope,omitempty"` + Name string `json:"name"` + Method string `json:"method"` + Path string `json:"path"` + Summary string `json:"summary,omitempty"` + Description string `json:"description,omitempty"` + Parameters []ParameterInfo `json:"parameters,omitempty"` + HasRequestBody bool `json:"has_request_body,omitempty"` + RequestBodySchemaRef string `json:"request_body_schema_ref,omitempty"` + RequiredScope string `json:"required_scope,omitempty"` } // ParameterInfo describes a single parameter of an API operation. @@ -42,8 +49,10 @@ type ParameterInfo struct { Type string `json:"type"` } -// resourcePathMap maps CLI resource names to their OpenAPI path prefixes. -var resourcePathMap = map[string][]string{ +// accountingResourcePathMap maps CLI resource names to their OpenAPI path +// keys for the MoneyForward Accounting API. Paths include the "/api/v3" +// prefix because that is how the accounting OpenAPI spec writes them. +var accountingResourcePathMap = map[string][]string{ "office": {"/api/v3/offices"}, "accounts": {"/api/v3/accounts"}, "journals": {"/api/v3/journals", "/api/v3/journals/{id}"}, @@ -62,19 +71,63 @@ var resourcePathMap = map[string][]string{ }, } -// New creates a Schema from raw YAML bytes of an OpenAPI spec. +// invoiceResourcePathMap maps CLI resource names to OpenAPI path keys for +// the MoneyForward Invoice API. +// +// The invoice OpenAPI spec declares `servers.url: /api/v3` and its path +// keys do NOT include the "/api/v3" prefix (e.g. `/billings`). The CLI +// HTTP layer adds the prefix back when building actual requests; here +// we keep the path map aligned with the spec. +var invoiceResourcePathMap = map[string][]string{ + "invoices": { + "/billings", + "/billings/{billing_id}", + "/invoice_template_billings", + }, + "invoice-partners": { + "/partners", + "/partners/{partner_id}", + }, + "invoice-departments": { + "/partners/{partner_id}/departments", + "/partners/{partner_id}/departments/{department_id}", + }, + "invoice-items": { + "/items", + "/items/{item_id}", + }, +} + +// New parses an OpenAPI spec and returns a Schema bound to the +// accounting path map. It is preserved for backwards compatibility; +// new callers should prefer NewAccounting or NewInvoice for clarity. func New(specData []byte) (*Schema, error) { + return NewAccounting(specData) +} + +// NewAccounting parses the accounting OpenAPI spec. +func NewAccounting(specData []byte) (*Schema, error) { + return newSchema(specData, accountingResourcePathMap) +} + +// NewInvoice parses the invoice OpenAPI spec. +func NewInvoice(specData []byte) (*Schema, error) { + return newSchema(specData, invoiceResourcePathMap) +} + +func newSchema(specData []byte, pathMap map[string][]string) (*Schema, error) { var spec map[string]any if err := yaml.Unmarshal(specData, &spec); err != nil { return nil, fmt.Errorf("failed to parse OpenAPI spec: %w", err) } - return &Schema{spec: spec}, nil + return &Schema{spec: spec, pathMap: pathMap}, nil } -// ListResources returns a sorted list of available CLI resource names. +// ListResources returns a sorted list of available CLI resource names +// for the API this Schema was constructed for. func (s *Schema) ListResources() []string { - resources := make([]string, 0, len(resourcePathMap)) - for name := range resourcePathMap { + resources := make([]string, 0, len(s.pathMap)) + for name := range s.pathMap { resources = append(resources, name) } sort.Strings(resources) @@ -83,7 +136,7 @@ func (s *Schema) ListResources() []string { // Describe returns detailed information about all operations for a given CLI resource. func (s *Schema) Describe(resource string) (*ResourceInfo, error) { - paths, ok := resourcePathMap[strings.ToLower(resource)] + paths, ok := s.pathMap[strings.ToLower(resource)] if !ok { return nil, fmt.Errorf("unknown resource %q; available resources: %s", resource, strings.Join(s.ListResources(), ", ")) @@ -103,6 +156,12 @@ func (s *Schema) Describe(resource string) (*ResourceInfo, error) { if !ok { continue } + // Path-item level parameters apply to every operation under + // this path and may be overridden by operation-level parameters + // with the same (name, in) tuple. The invoice spec relies on + // this for nested resources like + // /partners/{partner_id}/departments/{department_id}. + pathLevelParams := extractParameters(pathItem) for _, method := range []string{"get", "post", "put", "delete", "patch"} { opData, ok := pathItem[method].(map[string]any) if !ok { @@ -115,9 +174,10 @@ func (s *Schema) Describe(resource string) (*ResourceInfo, error) { } op.Summary = getString(opData, "summary") op.Description = cleanDescription(getString(opData, "description")) - op.Parameters = extractParameters(opData) + op.Parameters = mergeParameters(pathLevelParams, extractParameters(opData)) op.HasRequestBody = hasRequestBody(opData) - op.RequiredScope = extractScope(opData) + op.RequestBodySchemaRef = extractRequestBodySchemaRef(opData, s.spec) + op.RequiredScope = extractScope(opData, s.spec, op.Method) info.Operations = append(info.Operations, op) } } @@ -129,9 +189,10 @@ func (s *Schema) Describe(resource string) (*ResourceInfo, error) { return info, nil } -// extractParameters extracts parameter information from an operation map. -func extractParameters(opData map[string]any) []ParameterInfo { - rawParams, ok := opData["parameters"].([]any) +// extractParameters extracts parameter information from a map that has a +// "parameters" array (either an operation map or a path-item map). +func extractParameters(node map[string]any) []ParameterInfo { + rawParams, ok := node["parameters"].([]any) if !ok { return nil } @@ -147,11 +208,37 @@ func extractParameters(opData map[string]any) []ParameterInfo { Required: getBool(p, "required"), Type: extractParamType(p), } + if param.Name == "" { + continue + } params = append(params, param) } return params } +// mergeParameters returns a parameter list where path-item level entries +// are present unless the operation overrides them via the same +// (name, in) tuple. Operation-level entries are preserved as-is. +func mergeParameters(pathLevel, opLevel []ParameterInfo) []ParameterInfo { + if len(pathLevel) == 0 { + return opLevel + } + type key struct{ name, in string } + occupied := make(map[key]struct{}, len(opLevel)) + for _, p := range opLevel { + occupied[key{p.Name, p.In}] = struct{}{} + } + merged := make([]ParameterInfo, 0, len(pathLevel)+len(opLevel)) + for _, p := range pathLevel { + if _, ok := occupied[key{p.Name, p.In}]; ok { + continue + } + merged = append(merged, p) + } + merged = append(merged, opLevel...) + return merged +} + // extractParamType gets the type string from a parameter's schema. func extractParamType(p map[string]any) string { schemaData, ok := p["schema"].(map[string]any) @@ -185,9 +272,177 @@ func hasRequestBody(opData map[string]any) bool { return ok } -// extractScope extracts the first required OAuth2 scope from an operation. -func extractScope(opData map[string]any) string { - security, ok := opData["security"].([]any) +// extractRequestBodySchemaRef returns a human-readable reference for the +// request body schema, when one is declared. It performs only one level +// of $ref resolution: +// +// 1. If `requestBody.$ref` points at `#/components/requestBodies/X`, +// dereference it once. +// 2. Then read `content."application/json".schema.$ref` (or `.type` +// when no $ref) from that node. +// +// Returns the bare component name (e.g. "BillingNewTemplateCreateRequest") +// for refs, or the schema type string when the body schema is inline. +// Empty string means no request body or no JSON content was found. +func extractRequestBodySchemaRef(opData, spec map[string]any) string { + rawBody, ok := opData["requestBody"] + if !ok { + return "" + } + bodyMap, ok := rawBody.(map[string]any) + if !ok { + return "" + } + + // Resolve a top-level requestBody $ref one level. When the + // requestBody is referenced by name, prefer the component name + // itself for describe output unless the inner content explicitly + // points at a schema $ref (in which case that more specific name + // is more useful). This keeps the canonical body identifier + // (e.g. "BillingNewTemplateCreateRequest") visible even when the + // schema is inline as `type: object`. + if ref, ok := bodyMap["$ref"].(string); ok && ref != "" { + resolved, name := resolveRef(spec, ref, "requestBodies") + if resolved != nil { + if schemaRef := innerSchemaRefName(resolved); schemaRef != "" { + return schemaRef + } + } + if name != "" { + return name + } + return "" + } + + if name := schemaRefFromContent(bodyMap); name != "" { + return name + } + return "" +} + +// schemaRefFromContent reads body.content."application/json".schema and +// returns either a component name (for $ref) or the inline type name. +// Used when no enclosing requestBody component name is available. +func schemaRefFromContent(bodyNode map[string]any) string { + if name := innerSchemaRefName(bodyNode); name != "" { + return name + } + if t := schemaTypeFromContent(bodyNode); t != "" { + return t + } + return "" +} + +// innerSchemaRefName returns only the $ref-resolved schema name, +// ignoring inline type declarations. +func innerSchemaRefName(bodyNode map[string]any) string { + content, ok := bodyNode["content"].(map[string]any) + if !ok { + return "" + } + jsonNode, ok := content["application/json"].(map[string]any) + if !ok { + return "" + } + schemaNode, ok := jsonNode["schema"].(map[string]any) + if !ok { + return "" + } + ref, ok := schemaNode["$ref"].(string) + if !ok || ref == "" { + return "" + } + _, name := resolveRef(nil, ref, "schemas") + if name != "" { + return name + } + return ref +} + +func schemaTypeFromContent(bodyNode map[string]any) string { + content, ok := bodyNode["content"].(map[string]any) + if !ok { + return "" + } + jsonNode, ok := content["application/json"].(map[string]any) + if !ok { + return "" + } + schemaNode, ok := jsonNode["schema"].(map[string]any) + if !ok { + return "" + } + return getString(schemaNode, "type") +} + +// resolveRef looks up a "#/components/
/" reference. If +// spec is non-nil the referenced node is returned; the bare component +// name is returned regardless. Returns (nil, "") if the ref does not +// match the expected shape. +func resolveRef(spec map[string]any, ref, expectedSection string) (map[string]any, string) { + const prefix = "#/components/" + if !strings.HasPrefix(ref, prefix) { + return nil, "" + } + rest := strings.TrimPrefix(ref, prefix) + parts := strings.SplitN(rest, "/", 2) + if len(parts) != 2 { + return nil, "" + } + if parts[0] != expectedSection { + // Caller asked for a different section; still return the name. + return nil, parts[1] + } + if spec == nil { + return nil, parts[1] + } + components, ok := spec["components"].(map[string]any) + if !ok { + return nil, parts[1] + } + section, ok := components[parts[0]].(map[string]any) + if !ok { + return nil, parts[1] + } + node, ok := section[parts[1]].(map[string]any) + if !ok { + return nil, parts[1] + } + return node, parts[1] +} + +// extractScope extracts the required OAuth2 scope for an operation. +// If the operation declares no `security` of its own, we fall back to +// the spec's top-level `security` array (common for the invoice spec +// where read endpoints inherit the document-level security). +// +// When a security entry lists multiple scopes (the invoice global +// security, for example, lists both `mfc/invoice/data.write` and +// `mfc/invoice/data.read`), we pick the one that best matches the +// HTTP method: read-suffixed scopes for GET, write-suffixed scopes +// for everything else. Falling back to the first scope when no +// suffix matches keeps the accounting describe output unchanged. +// +// Both specs use the same `array-of-objects` shape (key is "OAuth2" +// for accounting, "AccessToken" for invoice); we iterate every key +// without hard-coding the list. +func extractScope(opData, spec map[string]any, method string) string { + if scope := scopeFromSecurityList(opData["security"], method); scope != "" { + return scope + } + if _, hasOwn := opData["security"]; hasOwn { + // Operation explicitly declares an empty security list, which + // per OpenAPI means "no auth required". Don't fall back. + return "" + } + if spec != nil { + return scopeFromSecurityList(spec["security"], method) + } + return "" +} + +func scopeFromSecurityList(raw any, method string) string { + security, ok := raw.([]any) if !ok { return "" } @@ -196,17 +451,42 @@ func extractScope(opData map[string]any) string { if !ok { continue } - scopes, ok := secMap["OAuth2"].([]any) + for _, scopesAny := range secMap { + scopes, ok := scopesAny.([]any) + if !ok { + continue + } + if scope := pickScopeForMethod(scopes, method); scope != "" { + return scope + } + } + } + return "" +} + +// pickScopeForMethod returns the scope from scopes that best matches +// the HTTP method. GET prefers a `.read` scope; mutating methods +// prefer `.write`. If no suffix matches, returns the first string +// scope (preserves prior behavior for specs with single-scope lists). +func pickScopeForMethod(scopes []any, method string) string { + var first string + preferReadFirst := strings.EqualFold(method, "GET") + for _, raw := range scopes { + s, ok := raw.(string) if !ok { continue } - if len(scopes) > 0 { - if s, ok := scopes[0].(string); ok { - return s - } + if first == "" { + first = s + } + if preferReadFirst && strings.HasSuffix(s, ".read") { + return s + } + if !preferReadFirst && strings.HasSuffix(s, ".write") { + return s } } - return "" + return first } // getString safely extracts a string value from a map. diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go index d74f1af..70dc70f 100644 --- a/internal/schema/schema_test.go +++ b/internal/schema/schema_test.go @@ -138,8 +138,8 @@ func TestListResources_ReturnsSortedList(t *testing.T) { s := mustNewSchema(t, testSpecYAML) resources := s.ListResources() - if len(resources) != len(resourcePathMap) { - t.Fatalf("ListResources() returned %d items, want %d", len(resources), len(resourcePathMap)) + if len(resources) != len(accountingResourcePathMap) { + t.Fatalf("ListResources() returned %d items, want %d", len(resources), len(accountingResourcePathMap)) } // Verify sorted order. @@ -468,7 +468,7 @@ func TestExtractParamType_ArrayItemsNoType(t *testing.T) { func TestExtractScope_NoSecurity(t *testing.T) { t.Parallel() opData := map[string]any{} - got := extractScope(opData) + got := extractScope(opData, nil, "") if got != "" { t.Errorf("extractScope() = %q, want empty", got) } @@ -483,7 +483,7 @@ func TestExtractScope_OAuth2WithScope(t *testing.T) { }, }, } - got := extractScope(opData) + got := extractScope(opData, nil, "") if got != "read:accounts" { t.Errorf("extractScope() = %q, want %q", got, "read:accounts") } @@ -502,7 +502,7 @@ func TestExtractScope_NonOAuth2First(t *testing.T) { }, }, } - got := extractScope(opData) + got := extractScope(opData, nil, "") if got != "write:data" { t.Errorf("extractScope() = %q, want %q", got, "write:data") } @@ -517,7 +517,7 @@ func TestExtractScope_OAuth2EmptyScopes(t *testing.T) { }, }, } - got := extractScope(opData) + got := extractScope(opData, nil, "") if got != "" { t.Errorf("extractScope() = %q, want empty", got) } @@ -530,7 +530,7 @@ func TestExtractScope_SecurityEntryNotMap(t *testing.T) { "not a map", }, } - got := extractScope(opData) + got := extractScope(opData, nil, "") if got != "" { t.Errorf("extractScope() = %q, want empty", got) } @@ -672,3 +672,297 @@ func TestCleanDescription_CombinedHTMLAndBlankLines(t *testing.T) { t.Errorf("cleanDescription(%q) = %q, want %q", input, got, want) } } + +// --------------------------------------------------------------------------- +// Invoice + path-item parameter merge + requestBody $ref tests +// --------------------------------------------------------------------------- + +const invoiceSpecYAML = ` +openapi: 3.1.0 +info: + title: Invoice API + version: 1.0.0 +servers: + - url: /api/v3 +paths: + /billings: + get: + operationId: get-billings + summary: Get billings + parameters: + - schema: + type: integer + minimum: 1 + in: query + name: page + - schema: + type: string + in: query + name: q + security: + - AccessToken: + - mfc/invoice/data.read + /invoice_template_billings: + post: + operationId: post-invoice-template-billings + summary: Create new invoice template billing + requestBody: + $ref: '#/components/requestBodies/BillingNewTemplateCreateRequest' + security: + - AccessToken: + - mfc/invoice/data.write + /partners/{partner_id}/departments/{department_id}: + parameters: + - schema: + type: string + name: partner_id + in: path + required: true + - schema: + type: string + name: department_id + in: path + required: true + get: + operationId: get-partner-department + summary: Get a department + security: + - AccessToken: + - mfc/invoice/data.read +components: + requestBodies: + BillingNewTemplateCreateRequest: + content: + application/json: + schema: + $ref: '#/components/schemas/BillingNewTemplate' + schemas: + BillingNewTemplate: + type: object +` + +func TestNewInvoice_ListsInvoiceResources(t *testing.T) { + t.Parallel() + s, err := NewInvoice([]byte(invoiceSpecYAML)) + if err != nil { + t.Fatalf("NewInvoice() error: %v", err) + } + got := s.ListResources() + want := []string{"invoice-departments", "invoice-items", "invoice-partners", "invoices"} + if len(got) != len(want) { + t.Fatalf("ListResources() = %v, want %v", got, want) + } + for i, name := range want { + if got[i] != name { + t.Errorf("ListResources()[%d] = %q, want %q", i, got[i], name) + } + } +} + +func TestSchema_Independent_Accounting_And_Invoice(t *testing.T) { + t.Parallel() + a, err := NewAccounting([]byte(testSpecYAML)) + if err != nil { + t.Fatalf("NewAccounting error: %v", err) + } + b, err := NewInvoice([]byte(invoiceSpecYAML)) + if err != nil { + t.Fatalf("NewInvoice error: %v", err) + } + accounting := a.ListResources() + invoice := b.ListResources() + + for _, r := range accounting { + if strings.HasPrefix(r, "invoice") { + t.Errorf("accounting schema unexpectedly contains %q", r) + } + } + for _, r := range invoice { + if !strings.HasPrefix(r, "invoice") { + t.Errorf("invoice schema unexpectedly contains %q", r) + } + } +} + +func TestDescribe_InvoicesShowsCreatePathAndScope(t *testing.T) { + t.Parallel() + s, err := NewInvoice([]byte(invoiceSpecYAML)) + if err != nil { + t.Fatalf("NewInvoice error: %v", err) + } + info, err := s.Describe("invoices") + if err != nil { + t.Fatalf("Describe error: %v", err) + } + var foundCreate bool + for _, op := range info.Operations { + if op.Path == "/invoice_template_billings" && op.Method == "POST" { + foundCreate = true + if op.RequiredScope != "mfc/invoice/data.write" { + t.Errorf("RequiredScope = %q, want mfc/invoice/data.write", op.RequiredScope) + } + if op.RequestBodySchemaRef != "BillingNewTemplate" { + t.Errorf("RequestBodySchemaRef = %q, want BillingNewTemplate", op.RequestBodySchemaRef) + } + } + } + if !foundCreate { + t.Error("did not find POST /invoice_template_billings in describe output") + } +} + +func TestDescribe_InvoiceDepartments_PathParametersMerged(t *testing.T) { + t.Parallel() + s, err := NewInvoice([]byte(invoiceSpecYAML)) + if err != nil { + t.Fatalf("NewInvoice error: %v", err) + } + info, err := s.Describe("invoice-departments") + if err != nil { + t.Fatalf("Describe error: %v", err) + } + // Find the get operation on the nested path and verify both + // path parameters appear after the path-level merge. + var op *OperationInfo + for i := range info.Operations { + if info.Operations[i].Path == "/partners/{partner_id}/departments/{department_id}" && info.Operations[i].Method == "GET" { + op = &info.Operations[i] + break + } + } + if op == nil { + t.Fatal("did not find nested department GET operation") + } + want := map[string]bool{"partner_id": false, "department_id": false} + for _, p := range op.Parameters { + if p.In != "path" { + continue + } + if _, ok := want[p.Name]; ok { + want[p.Name] = true + } + } + for name, found := range want { + if !found { + t.Errorf("path parameter %q not found after merge; got: %+v", name, op.Parameters) + } + } +} + +func TestExtractScope_AccessTokenKey(t *testing.T) { + t.Parallel() + op := map[string]any{ + "security": []any{ + map[string]any{ + "AccessToken": []any{"mfc/invoice/data.write"}, + }, + }, + } + got := extractScope(op, nil, "") + if got != "mfc/invoice/data.write" { + t.Errorf("extractScope = %q, want mfc/invoice/data.write", got) + } +} + +func TestExtractScope_FallsBackToTopLevel(t *testing.T) { + t.Parallel() + op := map[string]any{} // no operation-level security + spec := map[string]any{ + "security": []any{ + map[string]any{ + "AccessToken": []any{"mfc/invoice/data.read", "mfc/invoice/data.write"}, + }, + }, + } + got := extractScope(op, spec, "GET") + if got != "mfc/invoice/data.read" { + t.Errorf("extractScope = %q, want top-level fallback mfc/invoice/data.read", got) + } +} + +func TestExtractScope_OperationOverridesTopLevel(t *testing.T) { + t.Parallel() + op := map[string]any{ + "security": []any{ + map[string]any{"AccessToken": []any{"override.scope"}}, + }, + } + spec := map[string]any{ + "security": []any{ + map[string]any{"AccessToken": []any{"top-level.scope"}}, + }, + } + got := extractScope(op, spec, "GET") + if got != "override.scope" { + t.Errorf("extractScope = %q, operation should override", got) + } +} + +func TestExtractScope_OperationEmptySecurityOptsOut(t *testing.T) { + t.Parallel() + op := map[string]any{"security": []any{}} + spec := map[string]any{ + "security": []any{ + map[string]any{"AccessToken": []any{"top-level.scope"}}, + }, + } + if got := extractScope(op, spec, "GET"); got != "" { + t.Errorf("explicit empty security should opt out, got %q", got) + } +} + +func TestExtractScope_GETPrefersReadOverWrite(t *testing.T) { + t.Parallel() + // Mirrors the invoice spec global security: write listed first. + spec := map[string]any{ + "security": []any{ + map[string]any{"AccessToken": []any{"mfc/invoice/data.write", "mfc/invoice/data.read"}}, + }, + } + op := map[string]any{} // no operation-level security + if got := extractScope(op, spec, "GET"); got != "mfc/invoice/data.read" { + t.Errorf("GET should prefer .read scope, got %q", got) + } +} + +func TestExtractScope_POSTPrefersWriteOverRead(t *testing.T) { + t.Parallel() + spec := map[string]any{ + "security": []any{ + map[string]any{"AccessToken": []any{"mfc/invoice/data.read", "mfc/invoice/data.write"}}, + }, + } + op := map[string]any{} + if got := extractScope(op, spec, "POST"); got != "mfc/invoice/data.write" { + t.Errorf("POST should prefer .write scope, got %q", got) + } +} + +func TestExtractScope_FallsBackToFirstWhenNoSuffixMatches(t *testing.T) { + t.Parallel() + op := map[string]any{ + "security": []any{ + map[string]any{"OAuth2": []any{"custom-scope"}}, + }, + } + if got := extractScope(op, nil, "GET"); got != "custom-scope" { + t.Errorf("got %q, want fallback to first scope", got) + } +} + +func TestMergeParameters_OperationOverridesPathLevel(t *testing.T) { + t.Parallel() + pathLevel := []ParameterInfo{ + {Name: "id", In: "path", Required: true, Type: "string"}, + } + opLevel := []ParameterInfo{ + {Name: "id", In: "path", Required: true, Type: "integer"}, + } + merged := mergeParameters(pathLevel, opLevel) + if len(merged) != 1 { + t.Fatalf("merged length = %d, want 1", len(merged)) + } + if merged[0].Type != "integer" { + t.Errorf("operation override lost: type = %q, want integer", merged[0].Type) + } +} diff --git a/internal/validate/invoice.go b/internal/validate/invoice.go new file mode 100644 index 0000000..4c09a63 --- /dev/null +++ b/internal/validate/invoice.go @@ -0,0 +1,239 @@ +package validate + +import ( + "encoding/json" + "fmt" + "strings" + "time" + "unicode/utf8" + + invoicemodel "github.com/beatinaniwa/mf-cli/internal/model/invoice" +) + +// invoiceDateLayouts lists the date formats accepted by the Invoice +// API for billing_date / due_date / sales_date fields. +// +// The OpenAPI spec declares `format: date` and uses examples like +// "2023/08/24" (slash-separated). To stay forgiving we also accept the +// canonical ISO form "2006-01-02"; both are normalized server-side. +var invoiceDateLayouts = []string{ + "2006-01-02", + "2006/01/02", +} + +// ValidateInvoiceDate checks that s is a valid invoice-style date. +func ValidateInvoiceDate(s string) error { + for _, layout := range invoiceDateLayouts { + if _, err := time.Parse(layout, s); err == nil { + return nil + } + } + return fmt.Errorf("invalid date %q: expected YYYY-MM-DD or YYYY/MM/DD", s) +} + +// invoiceItemExciseValues mirrors the enum on +// `components.schemas.BillingItem.excise` (and CreateBillingItem). +var invoiceItemExciseValues = []string{ + "untaxable", + "non_taxable", + "tax_exemption", + "five_percent", + "eight_percent", + "eight_percent_as_reduced_tax_rate", + "ten_percent", +} + +// invoice spec field length limits derived from +// `components.requestBodies.BillingNewTemplateCreateRequest`. +const ( + invoiceTitleMaxLen = 200 + invoiceMemoMaxLen = 450 + invoicePaymentConditionMaxLen = 250 + invoiceBillingNumberMaxLen = 30 + invoiceNoteMaxLen = 2000 + invoiceDocumentNameMaxLen = 25 + invoiceTagMaxLen = 255 + invoiceItemNameMaxLen = 450 + invoiceItemDetailMaxLen = 200 + invoiceItemUnitMaxLen = 20 + invoiceItemDeliveryNumberMax = 30 +) + +// ValidateInvoiceCreateRequest applies client-side checks before +// posting an invoice draft. Mirrors the spec's `required` and +// `maxLength` / enum constraints; keeps the API call as the source of +// truth for everything else (e.g. department membership). +func ValidateInvoiceCreateRequest(req *invoicemodel.CreateBillingRequest) error { + if req == nil { + return fmt.Errorf("create request is nil") + } + if strings.TrimSpace(req.DepartmentID) == "" { + return fmt.Errorf("department_id is required") + } + if strings.TrimSpace(req.BillingDate) == "" { + return fmt.Errorf("billing_date is required") + } + if err := ValidateInvoiceDate(req.BillingDate); err != nil { + return fmt.Errorf("billing_date: %w", err) + } + if req.DueDate != nil && *req.DueDate != "" { + if err := ValidateInvoiceDate(*req.DueDate); err != nil { + return fmt.Errorf("due_date: %w", err) + } + } + if req.SalesDate != nil && *req.SalesDate != "" { + if err := ValidateInvoiceDate(*req.SalesDate); err != nil { + return fmt.Errorf("sales_date: %w", err) + } + } + if err := validateOptionalMaxLen("title", req.Title, invoiceTitleMaxLen); err != nil { + return err + } + if err := validateOptionalMaxLen("memo", req.Memo, invoiceMemoMaxLen); err != nil { + return err + } + if err := validateOptionalMaxLen("payment_condition", req.PaymentCondition, invoicePaymentConditionMaxLen); err != nil { + return err + } + if err := validateOptionalMaxLen("billing_number", req.BillingNumber, invoiceBillingNumberMaxLen); err != nil { + return err + } + if err := validateOptionalMaxLen("note", req.Note, invoiceNoteMaxLen); err != nil { + return err + } + if err := validateOptionalMaxLen("document_name", req.DocumentName, invoiceDocumentNameMaxLen); err != nil { + return err + } + if err := validateTagNames(req.TagNames); err != nil { + return err + } + for i, item := range req.Items { + if err := validateInvoiceCreateItem(i, item); err != nil { + return err + } + } + return nil +} + +// ValidateInvoiceUpdateRequest validates the optional fields on an +// update payload. No top-level field is required; we only check format +// when a value is supplied. +func ValidateInvoiceUpdateRequest(req *invoicemodel.UpdateBillingRequest) error { + if req == nil { + return fmt.Errorf("update request is nil") + } + if req.BillingDate != nil && *req.BillingDate != "" { + if err := ValidateInvoiceDate(*req.BillingDate); err != nil { + return fmt.Errorf("billing_date: %w", err) + } + } + if req.DueDate != nil && *req.DueDate != "" { + if err := ValidateInvoiceDate(*req.DueDate); err != nil { + return fmt.Errorf("due_date: %w", err) + } + } + if req.SalesDate != nil && *req.SalesDate != "" { + if err := ValidateInvoiceDate(*req.SalesDate); err != nil { + return fmt.Errorf("sales_date: %w", err) + } + } + if err := validateOptionalMaxLen("title", req.Title, invoiceTitleMaxLen); err != nil { + return err + } + if err := validateOptionalMaxLen("memo", req.Memo, invoiceMemoMaxLen); err != nil { + return err + } + if err := validateOptionalMaxLen("payment_condition", req.PaymentCondition, invoicePaymentConditionMaxLen); err != nil { + return err + } + if err := validateOptionalMaxLen("billing_number", req.BillingNumber, invoiceBillingNumberMaxLen); err != nil { + return err + } + if err := validateOptionalMaxLen("note", req.Note, invoiceNoteMaxLen); err != nil { + return err + } + if err := validateOptionalMaxLen("document_name", req.DocumentName, invoiceDocumentNameMaxLen); err != nil { + return err + } + if err := validateTagNames(req.TagNames); err != nil { + return err + } + return nil +} + +func validateInvoiceCreateItem(idx int, item invoicemodel.CreateBillingItem) error { + // Per spec: when item_id is omitted, excise is required. + if (item.ItemID == nil || *item.ItemID == "") && (item.Excise == nil || *item.Excise == "") { + return fmt.Errorf("items[%d]: excise is required when item_id is not set", idx) + } + if item.Excise != nil && *item.Excise != "" { + if err := ValidateEnum(*item.Excise, invoiceItemExciseValues); err != nil { + return fmt.Errorf("items[%d].excise: %w", idx, err) + } + } + if err := validateOptionalMaxLen(fmt.Sprintf("items[%d].name", idx), item.Name, invoiceItemNameMaxLen); err != nil { + return err + } + if item.Name != nil && len(strings.TrimSpace(*item.Name)) == 0 && item.ItemID == nil { + return fmt.Errorf("items[%d].name must not be blank", idx) + } + if err := validateOptionalMaxLen(fmt.Sprintf("items[%d].detail", idx), item.Detail, invoiceItemDetailMaxLen); err != nil { + return err + } + if err := validateOptionalMaxLen(fmt.Sprintf("items[%d].unit", idx), item.Unit, invoiceItemUnitMaxLen); err != nil { + return err + } + if err := validateOptionalMaxLen(fmt.Sprintf("items[%d].delivery_number", idx), item.DeliveryNumber, invoiceItemDeliveryNumberMax); err != nil { + return err + } + if item.Price != nil { + if err := validateInvoiceNumber(fmt.Sprintf("items[%d].price", idx), *item.Price); err != nil { + return err + } + } + if item.Quantity != nil { + if err := validateInvoiceNumber(fmt.Sprintf("items[%d].quantity", idx), *item.Quantity); err != nil { + return err + } + } + return nil +} + +func validateTagNames(tags *[]string) error { + if tags == nil { + return nil + } + for i, tag := range *tags { + if utf8.RuneCountInString(tag) > invoiceTagMaxLen { + return fmt.Errorf("tag_names[%d] exceeds %d characters", i, invoiceTagMaxLen) + } + } + return nil +} + +// validateOptionalMaxLen enforces an OpenAPI `maxLength` constraint, +// which is specified in characters (code points) — not bytes. Using +// len() would falsely reject otherwise-valid Japanese inputs because +// each character is 3 bytes in UTF-8. +func validateOptionalMaxLen(name string, ptr *string, max int) error { + if ptr == nil { + return nil + } + if utf8.RuneCountInString(*ptr) > max { + return fmt.Errorf("%s exceeds %d characters", name, max) + } + return nil +} + +func validateInvoiceNumber(name string, n json.Number) error { + s := string(n) + if s == "" { + return fmt.Errorf("%s must not be empty", name) + } + // Use json.Number's float parser to detect malformed values without + // committing to a particular numeric type. + if _, err := n.Float64(); err != nil { + return fmt.Errorf("%s: %w", name, err) + } + return nil +} diff --git a/internal/validate/invoice_test.go b/internal/validate/invoice_test.go new file mode 100644 index 0000000..0bea6d0 --- /dev/null +++ b/internal/validate/invoice_test.go @@ -0,0 +1,169 @@ +package validate + +import ( + "encoding/json" + "strings" + "testing" + + invoicemodel "github.com/beatinaniwa/mf-cli/internal/model/invoice" +) + +func TestValidateInvoiceDate(t *testing.T) { + cases := []struct { + in string + wantErr bool + }{ + {"2024-04-01", false}, + {"2024/04/01", false}, + {"2024-4-1", true}, + {"April 1", true}, + {"", true}, + } + for _, c := range cases { + err := ValidateInvoiceDate(c.in) + if (err != nil) != c.wantErr { + t.Errorf("ValidateInvoiceDate(%q) err=%v wantErr=%v", c.in, err, c.wantErr) + } + } +} + +func strPtr(s string) *string { return &s } +func numPtr(s string) *json.Number { + n := json.Number(s) + return &n +} + +func TestValidateInvoiceCreateRequest_Required(t *testing.T) { + cases := []struct { + name string + req *invoicemodel.CreateBillingRequest + want string + }{ + {"nil", nil, "create request is nil"}, + {"missing department_id", &invoicemodel.CreateBillingRequest{BillingDate: "2024-04-01"}, "department_id"}, + {"missing billing_date", &invoicemodel.CreateBillingRequest{DepartmentID: "d"}, "billing_date"}, + {"bad billing_date", &invoicemodel.CreateBillingRequest{DepartmentID: "d", BillingDate: "tomorrow"}, "billing_date"}, + } + for _, c := range cases { + err := ValidateInvoiceCreateRequest(c.req) + if err == nil { + t.Errorf("%s: expected error", c.name) + continue + } + if !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: error = %q, want substring %q", c.name, err.Error(), c.want) + } + } +} + +func TestValidateInvoiceCreateRequest_OK(t *testing.T) { + req := &invoicemodel.CreateBillingRequest{ + DepartmentID: "d", + BillingDate: "2024-04-01", + Title: strPtr("April"), + Items: []invoicemodel.CreateBillingItem{ + {Name: strPtr("Consult"), Price: numPtr("10000.25"), Quantity: numPtr("1"), Excise: strPtr("ten_percent")}, + }, + } + if err := ValidateInvoiceCreateRequest(req); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateInvoiceCreateRequest_TitleMaxLen(t *testing.T) { + long := strings.Repeat("x", 201) + req := &invoicemodel.CreateBillingRequest{ + DepartmentID: "d", + BillingDate: "2024-04-01", + Title: &long, + } + err := ValidateInvoiceCreateRequest(req) + if err == nil || !strings.Contains(err.Error(), "title exceeds") { + t.Errorf("expected title length error, got: %v", err) + } +} + +func TestValidateInvoiceCreateRequest_MultibyteTitleAllowed(t *testing.T) { + // 200 Japanese characters = 600 UTF-8 bytes. The spec's maxLength + // counts code points, not bytes, so this must be accepted. + jp := strings.Repeat("請", 200) + req := &invoicemodel.CreateBillingRequest{ + DepartmentID: "d", + BillingDate: "2024-04-01", + Title: &jp, + Items: []invoicemodel.CreateBillingItem{ + {Name: strPtr("x"), Excise: strPtr("ten_percent")}, + }, + } + if err := ValidateInvoiceCreateRequest(req); err != nil { + t.Errorf("200 multibyte chars should be accepted, got: %v", err) + } +} + +func TestValidateInvoiceCreateRequest_MultibyteTitleOverLimit(t *testing.T) { + jp := strings.Repeat("請", 201) + req := &invoicemodel.CreateBillingRequest{ + DepartmentID: "d", + BillingDate: "2024-04-01", + Title: &jp, + } + if err := ValidateInvoiceCreateRequest(req); err == nil { + t.Errorf("201 multibyte chars should be rejected") + } +} + +func TestValidateInvoiceCreateRequest_ItemsExciseEnum(t *testing.T) { + req := &invoicemodel.CreateBillingRequest{ + DepartmentID: "d", + BillingDate: "2024-04-01", + Items: []invoicemodel.CreateBillingItem{ + {Name: strPtr("x"), Excise: strPtr("foo")}, + }, + } + err := ValidateInvoiceCreateRequest(req) + if err == nil || !strings.Contains(err.Error(), "excise") { + t.Errorf("expected excise enum error, got: %v", err) + } +} + +func TestValidateInvoiceCreateRequest_ItemsExciseRequiredWithoutItemID(t *testing.T) { + req := &invoicemodel.CreateBillingRequest{ + DepartmentID: "d", + BillingDate: "2024-04-01", + Items: []invoicemodel.CreateBillingItem{ + {Name: strPtr("x")}, + }, + } + err := ValidateInvoiceCreateRequest(req) + if err == nil || !strings.Contains(err.Error(), "excise is required") { + t.Errorf("expected excise required error, got: %v", err) + } +} + +func TestValidateInvoiceCreateRequest_ItemsBadNumber(t *testing.T) { + req := &invoicemodel.CreateBillingRequest{ + DepartmentID: "d", + BillingDate: "2024-04-01", + Items: []invoicemodel.CreateBillingItem{ + {Name: strPtr("x"), Excise: strPtr("ten_percent"), Price: numPtr("not-a-number")}, + }, + } + err := ValidateInvoiceCreateRequest(req) + if err == nil || !strings.Contains(err.Error(), "price") { + t.Errorf("expected numeric error, got: %v", err) + } +} + +func TestValidateInvoiceUpdateRequest_OptionalFieldsOnly(t *testing.T) { + if err := ValidateInvoiceUpdateRequest(&invoicemodel.UpdateBillingRequest{}); err != nil { + t.Fatalf("update with no fields should succeed, got: %v", err) + } +} + +func TestValidateInvoiceUpdateRequest_BadDate(t *testing.T) { + bad := "tomorrow" + err := ValidateInvoiceUpdateRequest(&invoicemodel.UpdateBillingRequest{BillingDate: &bad}) + if err == nil || !strings.Contains(err.Error(), "billing_date") { + t.Errorf("expected billing_date error, got: %v", err) + } +} diff --git a/iv_openapi.yaml b/iv_openapi.yaml new file mode 100644 index 0000000..fa8af15 --- /dev/null +++ b/iv_openapi.yaml @@ -0,0 +1,3672 @@ +# Source: https://invoice.moneyforward.com/docs/api/v3/reference/iv_web_api.yaml +# Retrieved: 2026-05-08 +# info.version: 3.6.0 +# Upstream SHA-256: 28914fc04aad8853631ae04dee4db293c02580ec244a16d6ef00d41303152f81 +# (computed on the upstream YAML body before this header was prepended) +openapi: 3.1.0 +x-stoplight: + id: 1ho22pl6hug83 +info: + title: Money Forward Invoice API + version: 3.6.0 + summary: Money Forward Invoice API + description: |- + ### マネーフォワード クラウド請求書APIについて + https://biz.moneyforward.com/support/invoice/guide/api-guide/a03.html + ### マネーフォワード クラウド請求書API v3 スタートアップガイド + https://biz.moneyforward.com/support/invoice/guide/api-guide/a04.html +servers: + - url: /api/v3 +paths: + /office: + parameters: [] + get: + summary: Get my office + description: 事業者情報の取得 + tags: + - Office + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Office' + examples: + if-corporation-office: + $ref: '#/components/examples/CorporationOffice' + if-individual-office: + $ref: '#/components/examples/IndividualOffice' + '404': + description: Not Found + operationId: get-office + put: + summary: Update my office + description: 事業者情報の更新 + operationId: put-office + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Office' + examples: + if-corporation-office: + $ref: '#/components/examples/CorporationOffice' + if-individual-office: + $ref: '#/components/examples/IndividualOffice' + '400': + description: Bad Request + requestBody: + $ref: '#/components/requestBodies/OfficeUpdateRequest' + tags: + - Office + security: + - AccessToken: + - mfc/invoice/data.write + /office/registration_code: + put: + summary: Update registration code of my office + description: 適格請求書発行事業者番号の作成・更新 + operationId: put-office-registration-code + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + registration_code: + type: string + required: + - registration_code + examples: + Example: + value: + registration_code: T1234567890123 + '400': + description: Bad Request + tags: + - Office + requestBody: + content: + application/json: + schema: + type: object + properties: + registration_code: + type: string + pattern: '/^T\d{13}$/' + required: + - registration_code + examples: {} + security: + - AccessToken: + - mfc/invoice/data.write + delete: + summary: Delete registration code of my office + description: 適格請求書発行事業者番号の削除 + operationId: delete-office-registration_code + responses: + '204': + description: OK + tags: + - Office + security: + - AccessToken: + - mfc/invoice/data.write + /partners: + get: + summary: Get partners + description: 取引先一覧の取得 + tags: + - Partner + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Partner' + pagination: + $ref: '#/components/schemas/PaginationData' + required: + - data + - pagination + examples: + Example: + value: + data: + - id: 95PHKI9_FeSw3coTj673Cg + code: p41uz1dyvw3cj71qrkja + name: Ryu p41uz1dyvw3cj71qrkja + name_kana: p41uz1dyvw3cj71qrkja + name_suffix: 御中 + memo: p41uz1dyvw3cj71qrkja + created_at: '2023-03-20 13:39:28 +0900' + updated_at: '2023-03-20 13:39:28 +0900' + departments: + - id: qwc4iT7ZrywxipJCOqtZQg + zip: 123-4567 + tel: '1234567' + prefecture: 山形県 + address1: hb3m8kaxz9eex1czmpn2 + address2: hb3m8kaxz9eex1czmpn2 + person_name: hb3m8kaxz9eex1czmpn2 + person_title: hb3m8kaxz9eex1czmpn2 + person_dept: hb3m8kaxz9eex1czmpn2 + email: hb3m8kaxz9eex1czmpn2@moneyforward.com + cc_emails: hb3m8kaxz9eex1czmpn2@moneyforward.com + peppol_id: 0088:0000000000001 + office_member_name: hb3m8kaxz9eex1czmpn2 + office_member_id: '-UNhHGbLKnWH5xlrFhj2ow' + created_at: '2023-03-20 13:44:52 +0900' + updated_at: '2023-03-20 13:44:52 +0900' + pagination: + total_count: 3 + total_pages: 3 + per_page: 1 + current_page: 3 + operationId: get-partners + parameters: + - schema: + type: string + in: query + name: name + description: 'Partner name, it can be specified multiple value by separating them with a comma.' + - schema: + type: string + in: query + name: code + description: 'Partner code, it can be specified multiple value by separating them with a comma.' + - schema: + type: string + in: query + name: name_kana + description: 'Name of partner with katakana, it can be specified multiple value by separating them with a comma.' + - schema: + type: string + in: query + name: partner_pic + description: "Name of a person in charge of the partner, it can be specified multiple value by separating them with a comma." + - schema: + type: string + in: query + name: office_pic + description: "Office member's name in charge of the department, it can be specified multiple value by separating them with a comma." + - schema: + type: integer + minimum: 1 + in: query + name: page + description: 'default: 1' + - schema: + type: integer + minimum: 1 + maximum: 100 + in: query + name: per_page + description: 'default: 100' + post: + summary: Create new partner + description: 取引先の作成 + operationId: post-partners + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Partner' + examples: + Example: + $ref: '#/components/examples/PartnerWithDepartment' + '400': + description: Bad Request + requestBody: + $ref: '#/components/requestBodies/PartnerCreateRequest' + tags: + - Partner + security: + - AccessToken: + - mfc/invoice/data.write + '/partners/{partner_id}': + parameters: + - schema: + type: string + name: partner_id + in: path + required: true + get: + summary: Get a partner + description: 取引先の取得 + tags: + - Partner + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Partner' + examples: + with-departments: + $ref: '#/components/examples/PartnerWithDepartment' + without-departments: + $ref: '#/components/examples/PartnerWithoutDepartment' + '404': + description: Not Found + operationId: get-partners-id + put: + summary: Update a partner + description: 取引先の更新 + operationId: put-partners-id + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Partner' + examples: + Example: + $ref: '#/components/examples/PartnerWithDepartment' + '400': + description: Bad Request + requestBody: + $ref: '#/components/requestBodies/PartnerUpdateRequest' + tags: + - Partner + security: + - AccessToken: + - mfc/invoice/data.write + delete: + summary: Delete a partner + description: 取引先の削除 + operationId: delete-partners-id + responses: + '204': + description: No Content + '400': + description: Bad Request + tags: + - Partner + security: + - AccessToken: + - mfc/invoice/data.write + '/partners/{partner_id}/departments': + parameters: + - schema: + type: string + name: partner_id + in: path + required: true + get: + summary: Get departments + description: 取引先部署一覧の取得 + tags: + - Department + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Department' + pagination: + $ref: '#/components/schemas/PaginationData' + required: + - data + - pagination + examples: + Example: + value: + data: + - id: qwc4iT7ZrywxipJCOqtZQg + zip: 123-4567 + tel: '1234567' + prefecture: 山形県 + address1: hb3m8kaxz9eex1czmpn2 + address2: hb3m8kaxz9eex1czmpn2 + person_name: hb3m8kaxz9eex1czmpn2 + person_title: hb3m8kaxz9eex1czmpn2 + person_dept: hb3m8kaxz9eex1czmpn2 + email: hb3m8kaxz9eex1czmpn2@moneyforward.com + cc_emails: hb3m8kaxz9eex1czmpn2@moneyforward.com + peppol_id: 0088:0000000000001 + office_member_name: hb3m8kaxz9eex1czmpn2 + office_member_id: '-UNhHGbLKnWH5xlrFhj2ow' + created_at: '2023-03-20 13:44:52 +0900' + updated_at: '2023-03-20 13:44:52 +0900' + pagination: + total_count: 1 + total_pages: 1 + per_page: 1 + current_page: 1 + '404': + description: 'A partner is not found ' + operationId: get-partners-id-partner-departments + post: + summary: Create new department + description: 取引先部署の作成 + operationId: post-partners-id-departments + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Department' + examples: + Example: + $ref: '#/components/examples/Department' + '400': + description: Bad Request + '404': + description: Partner is not found + requestBody: + $ref: '#/components/requestBodies/DepartmentCreateRequest' + tags: + - Department + security: + - AccessToken: + - mfc/invoice/data.write + '/partners/{partner_id}/departments/{department_id}': + parameters: + - schema: + type: string + name: partner_id + in: path + required: true + - schema: + type: string + name: department_id + in: path + required: true + get: + summary: Get a department + description: 取引先部署の取得 + tags: + - Department + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Department' + examples: + Example: + $ref: '#/components/examples/Department' + '404': + description: |- + Partner is not found + Department is not found + operationId: get-partners-partner_id-departments-id + put: + summary: Update a department + description: 取引先部署の更新 + operationId: put-partners-partner_id-departments-id + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Department' + examples: + Example: + $ref: '#/components/examples/Department' + '400': + description: Bad Request + '404': + description: Partner is not found + requestBody: + $ref: '#/components/requestBodies/DepartmentUpdateRequest' + tags: + - Department + security: + - AccessToken: + - mfc/invoice/data.write + delete: + summary: Delete a department + description: 取引先部署の削除 + operationId: delete-partners-partner_id-departments-id + responses: + '204': + description: No Content + '400': + description: Bad Request + '404': + description: Partner is not found + tags: + - Department + security: + - AccessToken: + - mfc/invoice/data.write + /items: + get: + summary: Get items + description: 品目一覧の取得 + tags: + - Item + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Item' + pagination: + $ref: '#/components/schemas/PaginationData' + required: + - data + - pagination + examples: + if-corporation-office: + value: + data: + - id: t93_uqoFUT_EnX85CJ16XA + name: Name + code: Code + detail: Detail + unit: Unit + price: '1234.1' + quantity: '1' + excise: ten_percent + created_at: '2022-07-14 13:14:03 +0900' + updated_at: '2023-01-27 16:19:56 +0900' + pagination: + total_count: 1 + total_pages: 1 + per_page: 1 + current_page: 1 + if-individual-office: + value: + data: + - id: t93_uqoFUT_EnX85CJ16XA + name: Name + code: Code + detail: Detail + unit: Unit + price: '1234.1' + is_deduct_withholding_tax: true + quantity: '1' + excise: ten_percent + created_at: '2022-07-14 13:14:03 +0900' + updated_at: '2023-01-27 16:19:56 +0900' + pagination: + total_count: 1 + total_pages: 1 + per_page: 1 + current_page: 1 + operationId: get-items + parameters: + - schema: + type: string + in: query + name: name + description: 'Item name, it can be specified multiple value by separating them with a comma.' + - schema: + type: string + in: query + name: code + description: 'Item code, it can be specified multiple value by separating them with a comma.' + - schema: + type: integer + in: query + name: page + description: 'default: 1' + - schema: + type: integer + in: query + name: per_page + description: 'default: 100' + post: + summary: Create new item + description: 品目の作成 + operationId: post-items + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Item' + examples: + if-corporation-office: + $ref: '#/components/examples/IvItemWithoutIsDeductWithHoldingTax' + if-individual-office: + $ref: '#/components/examples/IvItemWithIsDeductWithHoldingTax' + '400': + description: Bad Request + requestBody: + $ref: '#/components/requestBodies/ItemCreateRequest' + tags: + - Item + security: + - AccessToken: + - mfc/invoice/data.write + '/items/{item_id}': + parameters: + - schema: + type: string + name: item_id + in: path + required: true + get: + summary: Get an item + description: 品目の取得 + tags: + - Item + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Item' + examples: + if-corporation-office: + $ref: '#/components/examples/IvItemWithoutIsDeductWithHoldingTax' + if-individual-office: + $ref: '#/components/examples/IvItemWithIsDeductWithHoldingTax' + '404': + description: Not Found + operationId: get-items-id + put: + summary: Update an item + description: 品目の更新 + operationId: put-items-id + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Item' + examples: + if-corporation-office: + $ref: '#/components/examples/IvItemWithoutIsDeductWithHoldingTax' + if-individual-office: + $ref: '#/components/examples/IvItemWithIsDeductWithHoldingTax' + '400': + description: Bad Request + requestBody: + $ref: '#/components/requestBodies/ItemUpdateRequest' + tags: + - Item + security: + - AccessToken: + - mfc/invoice/data.write + delete: + summary: Delete an item + description: 品目の削除 + operationId: delete-items-id + responses: + '204': + description: No Content + '400': + description: Bad Request + tags: + - Item + security: + - AccessToken: + - mfc/invoice/data.write + /billings: + get: + summary: Get billings + description: 請求書の取得 + tags: + - Billing + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Billing' + pagination: + $ref: '#/components/schemas/PaginationData' + required: + - data + - pagination + operationId: get-billings + parameters: + - schema: + type: integer + minimum: 1 + in: query + name: page + description: 'default: 1' + - schema: + type: integer + minimum: 1 + maximum: 100 + in: query + name: per_page + description: 'default: 100' + - schema: + type: string + enum: + - billing_date + - due_date + - sales_date + - created_at + - updated_at + in: query + name: range_key + description: 期間絞込対象 + - schema: + type: string + format: date + in: query + name: from + - schema: + type: string + format: date + in: query + name: to + - schema: + type: string + in: query + name: q + description: 検索文字列。取引先(完全一致)、ステータス、件名etc + - schema: + type: string + in: query + name: partner_id + - schema: + type: string + in: query + name: document_number + description: | + 請求書番号
+ ※qが指定されている場合、このパラメータは検索に使用されません + - schema: + type: string + in: query + name: status + description: | + ステータス。例:下書き, ロック中, 未ロック
+ ※qが指定されている場合、このパラメータは検索に使用されません + - schema: + type: string + in: query + name: partner_name + description: | + 取引先
+ ※qが指定されている場合、このパラメータは検索に使用されません + - schema: + type: string + in: query + name: tags + description: | + タグ。例:タグ1, タグ2
+ ※qが指定されている場合、このパラメータは検索に使用されません + /invoice_template_billings: + post: + summary: Create new invoice template billing + description: インボイス制度に対応した形式の請求書の作成 + operationId: post-invoice-template-billings + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Billing' + examples: + if-corporation-office: + $ref: '#/components/examples/NewBillingWithoutDeductPrice' + if-individual-office: + $ref: '#/components/examples/NewBillingWithDeductPrice' + '400': + description: Bad Request + requestBody: + $ref: '#/components/requestBodies/BillingNewTemplateCreateRequest' + tags: + - Billing + security: + - AccessToken: + - mfc/invoice/data.write + '/billings/{billing_id}': + parameters: + - schema: + type: string + name: billing_id + in: path + required: true + get: + summary: Get a billing + description: 請求書の取得 + tags: + - Billing + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Billing' + '404': + description: Not Found + operationId: get-billings-id + put: + summary: Update a billing + description: 請求書の更新 + operationId: put-billings-id + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Billing' + requestBody: + $ref: '#/components/requestBodies/BillingUpdateRequest' + tags: + - Billing + security: + - AccessToken: + - mfc/invoice/data.write + delete: + summary: Delete a billing + description: 請求書の削除 + operationId: delete-billings-id + responses: + '204': + description: No Content + tags: + - Billing + security: + - AccessToken: + - mfc/invoice/data.write + '/billings/{billing_id}/items': + parameters: + - schema: + type: string + name: billing_id + in: path + required: true + get: + summary: Get BillingItems of a billing + description: 請求書に紐づく品目一覧の取得 + tags: + - Billing + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/BillingItem' + pagination: + $ref: '#/components/schemas/PaginationData' + required: + - data + - pagination + '404': + description: A billing is not found + operationId: get-billings-id-items + post: + summary: Attach a BillingItem into a billing + description: 請求書に品目を追加 + operationId: post-billings-id-items + responses: + '201': + description: Created + '400': + description: Bad Request + '404': + description: A billing is not found + requestBody: + $ref: '#/components/requestBodies/BillingItemAttachRequest' + tags: + - Billing + security: + - AccessToken: + - mfc/invoice/data.write + '/billings/{billing_id}/items/{item_id}': + parameters: + - schema: + type: string + name: billing_id + in: path + required: true + - schema: + type: string + name: item_id + in: path + required: true + get: + summary: Get a BillingItem if a billing has + description: 請求書に紐づく品目の取得 + tags: + - Billing + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/BillingItem' + '404': + description: |- + A billing is not found + An item is not found + operationId: get-billings-billing_id-items-id + delete: + summary: Detach a BillingItem from a billing + description: 請求書に紐づく品目の削除 + operationId: delete-billings-billing_id-items-id + responses: + '204': + description: No Content + '400': + description: Bad Request + '404': + description: A billing is not found + tags: + - Billing + security: + - AccessToken: + - mfc/invoice/data.write + '/billings/{billing_id}/payment_status': + parameters: + - schema: + type: string + name: billing_id + in: path + required: true + put: + summary: Update payment status of a billing + description: 請求書の入金ステータス変更 + operationId: put-billings-billing_id-payment_status + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Billing' + '400': + description: Bad Request + '404': + description: A billing is not found + requestBody: + $ref: '#/components/requestBodies/PaymentStatusUpdateRequest' + tags: + - Billing + security: + - AccessToken: + - mfc/invoice/data.write + '/billings/{billing_id}/posting': + parameters: + - schema: + type: string + name: billing_id + in: path + required: true + post: + summary: Apply to post the billing + description: 請求書の郵送依頼 + operationId: post-billings-billing_id-posting + responses: + '201': + description: Created + '400': + description: Bad Request + '402': + description: Payment Required + '404': + description: Not Found + '422': + description: Unprocessable Entity + requestBody: + content: + application/json: + schema: + type: object + properties: + upload_to_cloud_box: + type: boolean + default: true + description: | + クラウドBoxに保存するかどうか + * `true` - クラウドBoxに保存する + * `false` - クラウドBoxに保存しない + tags: + - Billing + security: + - AccessToken: + - mfc/invoice/data.write + delete: + summary: Cancel to post the billing + description: 請求書の郵送キャンセル + operationId: delete-billings-billing_id-posting + responses: + '204': + description: No Content + '400': + description: Bad Request + '404': + description: Not Found + tags: + - Billing + security: + - AccessToken: + - mfc/invoice/data.write + /quotes: + get: + summary: Get quotes + description: 見積書一覧の取得 + tags: + - Quote + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Quote' + pagination: + $ref: '#/components/schemas/PaginationData' + required: + - data + - pagination + operationId: get-quotes + parameters: + - schema: + type: integer + minimum: 1 + in: query + name: page + description: 'default: 1' + - schema: + type: integer + maximum: 100 + minimum: 1 + in: query + name: per_page + description: 'default: 100' + - schema: + type: string + enum: + - quote_date + - expired_date + - created_at + - updated_at + in: query + name: range_key + description: 期間絞込対象 + - schema: + type: string + format: date + in: query + name: from + - schema: + type: string + format: date + in: query + name: to + - schema: + type: string + in: query + name: q + description: 検索文字列。取引先(完全一致)、ステータス、件名etc + - schema: + type: string + in: query + name: partner_id + - schema: + type: string + in: query + name: document_number + description: | + 請求書番号
+ ※qが指定されている場合、このパラメータは検索に使用されません + - schema: + type: string + in: query + name: status + description: | + ステータス。例:下書き, ロック中, 未ロック
+ ※qが指定されている場合、このパラメータは検索に使用されません + - schema: + type: string + in: query + name: partner_name + description: | + 取引先
+ ※qが指定されている場合、このパラメータは検索に使用されません + - schema: + type: string + in: query + name: tags + description: | + タグ。例:タグ1, タグ2
+ ※qが指定されている場合、このパラメータは検索に使用されません + post: + summary: Create new quote + description: 見積書の作成 + operationId: post-quotes + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Quote' + examples: + if-corporation-office: + $ref: '#/components/examples/QuoteWithoutDeductPrice' + if-individual-office: + $ref: '#/components/examples/QuoteWithDeductPrice' + '400': + description: Bad Request + requestBody: + $ref: '#/components/requestBodies/QuoteCreateRequest' + tags: + - Quote + security: + - AccessToken: + - mfc/invoice/data.write + '/quotes/{quote_id}': + parameters: + - schema: + type: string + name: quote_id + in: path + required: true + get: + summary: Get a quote + description: 見積書の作成 + tags: + - Quote + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Quote' + application/xml: + schema: + $ref: '#/components/schemas/Quote' + '404': + description: Not Found + operationId: get-quotes-quote_id + put: + summary: Update a quote + description: 見積書の更新 + operationId: put-quotes-quote_id + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Quote' + '400': + description: Bad Request + '404': + description: Not Found + requestBody: + $ref: '#/components/requestBodies/QuoteUpdateRequest' + tags: + - Quote + security: + - AccessToken: + - mfc/invoice/data.write + delete: + summary: Delete a quote + description: 見積書の削除 + operationId: delete-quotes-quote_id + responses: + '204': + description: No Content + '400': + description: Bad Request + '404': + description: Not Found + tags: + - Quote + security: + - AccessToken: + - mfc/invoice/data.write + '/quotes/{quote_id}/items': + parameters: + - schema: + type: string + name: quote_id + in: path + required: true + get: + summary: Get items of a quote + description: 見積書に紐づく品目一覧の取得 + tags: + - Quote + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Item' + pagination: + $ref: '#/components/schemas/PaginationData' + required: + - data + - pagination + '404': + description: A quote is not found + operationId: get-quotes-id-items + post: + summary: Attach an item into a quote + description: 見積書に品目を追加 + operationId: post-quotes-quote_id-items + responses: + '201': + description: Created + '400': + description: Bad Request + '404': + description: A quote is not found + requestBody: + $ref: '#/components/requestBodies/QuoteItemAttachRequest' + tags: + - Quote + security: + - AccessToken: + - mfc/invoice/data.write + '/quotes/{quote_id}/items/{item_id}': + parameters: + - schema: + type: string + name: quote_id + in: path + required: true + - schema: + type: string + name: item_id + in: path + required: true + get: + summary: Get an item if a quote has + description: 見積書に紐づく品目を取得 + tags: + - Quote + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Item' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Item' + operationId: get-quotes-quote_id-items-id + delete: + summary: Detach the item from a quote + description: 見積書に紐づく品目を削除 + operationId: delete-quotes-quote_id-items-id + responses: + '204': + description: No Content + '400': + description: Bad Request + '404': + description: A quote is not found + tags: + - Quote + security: + - AccessToken: + - mfc/invoice/data.write + '/quotes/{quote_id}/posting': + parameters: + - schema: + type: string + name: quote_id + in: path + required: true + post: + summary: Apply to post the quote + description: 見積書の郵送依頼 + operationId: post-quotes-quote_id-posting + responses: + '201': + description: Created + '400': + description: Bad Request + '402': + description: Payment Required + '404': + description: Not Found + '422': + description: Unprocessable Entity + requestBody: + content: + application/json: + schema: + type: object + properties: + upload_to_cloud_box: + type: boolean + default: true + description: | + クラウドBoxに保存するかどうか + * `true` - クラウドBoxに保存する + * `false` - クラウドBoxに保存しない + tags: + - Quote + security: + - AccessToken: + - mfc/invoice/data.write + delete: + summary: Cancel to post the quote + description: 見積書の郵送キャンセル + operationId: delete-quotes-quote_id-posting + responses: + '204': + description: No Content + '400': + description: Bad Request + '404': + description: Not Found + tags: + - Quote + security: + - AccessToken: + - mfc/invoice/data.write + '/quotes/{quote_id}/order_status': + parameters: + - schema: + type: string + name: quote_id + in: path + required: true + put: + summary: Update order status of the quote + description: 見積書のステータス更新 + operationId: put-quote-quote_id-order_status + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Quote' + requestBody: + content: + application/json: + schema: + type: object + properties: + order_status: + type: string + description: | + 受注ステータス: + * `-1` - 失注 + * `0` - 未設定 + * `1` - 未受注 + * `2` - 受注済み + enum: + - "-1" + - "0" + - "1" + - "2" + required: + - order_status + tags: + - Quote + security: + - AccessToken: + - mfc/invoice/data.write + '/quotes/{quote_id}/convert_to_billing': + parameters: + - schema: + type: string + name: quote_id + in: path + required: true + post: + summary: Convert the quote to billing + description: 見積書を請求書に変換 + operationId: post-quotes-quote_id-convert_to_billing + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Billing' + '404': + description: Not Found + tags: + - Quote + security: + - AccessToken: + - mfc/invoice/data.write + /sent_histories: + get: + summary: Get sent histories + description: 送付履歴一覧の取得 + tags: + - SentHistory + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/SentHistory' + pagination: + $ref: '#/components/schemas/PaginationData' + required: + - data + - pagination + examples: + Example: + value: + data: + - id: 21 + type: メール + operator_id: fbeo9WVrdW36B1CKP3KASg + document_type: 請求書 + document_id: dLyLiVH5XrNW9OdUw4aYHQ + from: do_not_reply@moneyforward.com + to: to@moneyforward.com + cc: '' + sender_name: '' + replay_to: '' + sent_at: '2022-10-12T15:52:40.000+09:00' + pagination: + total_count: 3 + total_pages: 3 + per_page: 1 + current_page: 1 + operationId: get-sent_histories + parameters: + - schema: + type: integer + minimum: 1 + in: query + name: page + description: 'default: 1' + - schema: + type: integer + minimum: 1 + maximum: 100 + in: query + name: per_page + description: 'default: 100' +components: + schemas: + Partner: + title: Partner + x-stoplight: + id: mnavdatyrxciq + type: object + properties: + id: + type: string + code: + type: string + name: + type: string + name_kana: + type: string + name_suffix: + type: string + memo: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + departments: + type: array + items: + $ref: '#/components/schemas/Department' + payment_deadline_setting: + nullable: true + $ref: '#/components/schemas/PaymentDeadlineSetting' + required: + - id + - name + - created_at + - updated_at + - departments + Office: + title: Office + type: object + description: '' + properties: + id: + type: string + name: + type: string + zip: + type: string + prefecture: + type: string + address1: + type: string + address2: + type: string + tel: + type: string + fax: + type: string + office_type: + type: string + office_code: + type: string + registration_code: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: + - id + - created_at + - updated_at + Department: + title: Department + x-stoplight: + id: bq8upk7nj10bc + type: object + properties: + id: + type: string + zip: + type: string + tel: + type: string + prefecture: + type: string + address1: + type: string + address2: + type: string + person_name: + type: string + person_title: + type: string + person_dept: + type: string + email: + type: string + cc_emails: + type: string + peppol_id: + type: string + office_member_id: + type: string + office_member_name: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: + - id + PaymentDeadlineSetting: + title: PaymentDeadlineSetting + type: object + properties: + due_month: + type: string + description: | + 支払期日(月): + * `this_month` - 当月 + * `next_month` - 翌月 + * `two_months_after` - 2ヶ月後 + * `three_months_after` - 3ヶ月後 + * `four_months_after` - 4ヶ月後 + * `five_months_after` - 5ヶ月後 + * `six_months_after` - 6ヶ月後 + enum: + - this_month + - next_month + - two_months_after + - three_months_after + - four_months_after + - five_months_after + - six_months_after + due_date: + type: integer + description: | + Enter the payment due date. + If you want to set it to the last day of the month, set it to "-1". + If you want to set it to a specific day, set it to a number between 1 and 31. + Note: 0 is not a valid value. + If no corresponding day exists, the last day of the month will be automatically set. + minimum: -1 + maximum: 31 + contingency_day: + type: string + description: | + 支払期日が休日の場合の処理: + * `move_to_earlier_day` - 前倒し + * `keep_as_is` - そのまま + * `move_to_later_day` - 後倒し + enum: + - move_to_earlier_day + - keep_as_is + - move_to_later_day + required: + - due_month + - due_date + - contingency_day + Item: + title: Item + x-stoplight: + id: hw5km77y0ypvw + type: object + properties: + id: + type: string + name: + type: string + code: + type: string + detail: + type: string + unit: + type: string + price: + type: string + quantity: + type: string + is_deduct_withholding_tax: + type: boolean + description: | + 源泉徴収税額の有り無し: + * 事業者が法人の時: null + * 事業者が個人事業主: true or false + excise: + type: string + description: | + 税率: + * `untaxable` - 不課税 + * `non_taxable` - 非課税 + * `tax_exemption` - 免税 + * `five_percent` - 5% + * `eight_percent` - 8% + * `eight_percent_as_reduced_tax_rate` - 8%(軽減税率) + * `ten_percent` - 10% + enum: + - untaxable + - non_taxable + - tax_exemption + - five_percent + - eight_percent + - eight_percent_as_reduced_tax_rate + - ten_percent + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: + - id + - name + - code + - created_at + - updated_at + BillingItem: + title: BillingItem + description: 請求書の品目 + x-stoplight: + id: hw5km88y0ypvw + type: object + properties: + id: + type: string + name: + type: string + code: + type: string + detail: + type: string + unit: + type: string + price: + type: string + quantity: + type: string + is_deduct_withholding_tax: + type: boolean + description: | + 源泉徴収税額の有り無し: + * 事業者が法人の時: null + * 事業者が個人事業主: true or false + excise: + type: string + description: | + 税率: + * `untaxable` - 不課税 + * `non_taxable` - 非課税 + * `tax_exemption` - 免税 + * `five_percent` - 5% + * `eight_percent` - 8% + * `eight_percent_as_reduced_tax_rate` - 8%(軽減税率) + * `ten_percent` - 10% + enum: + - untaxable + - non_taxable + - tax_exemption + - five_percent + - eight_percent + - eight_percent_as_reduced_tax_rate + - ten_percent + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + delivery_number: + type: string + delivery_date: + type: string + format: date + example: "2023/08/24" + required: + - id + - name + - code + - created_at + - updated_at + BillingConfig: + title: BillingConfig + description: 請求書の詳細設定 + x-stoplight: + id: hw5km88y0y1vw + type: object + properties: + rounding: + type: string + description: | + 明細行ごとの端数処理: + * `round_down` - 切り捨て + * `round_up` - 切り上げ + * `round_off` - 四捨五入 + enum: + - round_down + - round_up + - round_off + rounding_consumption_tax: + type: string + description: | + 消費税の端数処理: + * `round_down` - 切り捨て + * `round_up` - 切り上げ + * `round_off` - 四捨五入 + enum: + - round_down + - round_up + - round_off + consumption_tax_display_type: + type: string + description: | + 消費税の表示方式: + * `internal` - 内税 + * `external` - 外税 + enum: + - internal + - external + required: + - rounding + - rounding_consumption_tax + - consumption_tax_display_type + Billing: + title: Billing + x-stoplight: + id: dg6xplhmlhjct + type: object + properties: + id: + type: string + pdf_url: + type: string + operator_id: + type: string + department_id: + type: string + member_id: + type: string + member_name: + type: string + partner_id: + type: string + partner_name: + type: string + office_id: + type: string + office_name: + type: string + office_detail: + type: string + title: + type: string + memo: + type: string + payment_condition: + type: string + billing_date: + type: string + format: date + example: "2023/08/24" + due_date: + type: string + format: date + example: "2023/08/24" + sales_date: + type: string + format: date + example: "2023/08/24" + billing_number: + type: string + note: + type: string + document_name: + type: string + payment_status: + type: string + description: | + 入金ステータス: + * `0` - 未設定 + * `1` - 未入金 + * `2` - 入金済み + * `3` - 未払い + * `4` - 振込済み + enum: + - 未設定 + - 未入金 + - 入金済み + - 未払い + - 振込済み + email_status: + type: string + description: | + メールステータス: + * `null` - 未送信 + * `sent` - 送付済み + * `already_read` - 受領済み + * `received` - 受信 + enum: + - 未送信 + - 送付済み + - 受領済み + - 受信 + posting_status: + type: string + description: | + 郵送ステータス: + * `null` - 未郵送 + * `request` - 郵送依頼 + * `sent` - 郵送済み + * `cancel` - 郵送取消 + * `error` - 郵送失敗 + enum: + - 未郵送 + - 郵送依頼 + - 郵送済み + - 郵送取消 + - 郵送失敗 + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + is_downloaded: + type: boolean + is_locked: + type: boolean + deduct_price: + type: string + description: 'Only return if my office type is individual ' + tag_names: + type: array + items: + type: string + items: + type: array + items: + $ref: '#/components/schemas/BillingItem' + excise_price: + type: string + excise_price_of_untaxable: + type: string + excise_price_of_non_taxable: + type: string + excise_price_of_tax_exemption: + type: string + excise_price_of_five_percent: + type: string + excise_price_of_eight_percent: + type: string + excise_price_of_eight_percent_as_reduced_tax_rate: + type: string + excise_price_of_ten_percent: + type: string + subtotal_price: + type: string + subtotal_of_untaxable_excise: + type: string + subtotal_of_non_taxable_excise: + type: string + subtotal_of_tax_exemption_excise: + type: string + subtotal_of_five_percent_excise: + type: string + subtotal_of_eight_percent_excise: + type: string + subtotal_of_eight_percent_as_reduced_tax_rate_excise: + type: string + subtotal_of_ten_percent_excise: + type: string + subtotal_with_tax_of_untaxable_excise: + type: string + subtotal_with_tax_of_non_taxable_excise: + type: string + subtotal_with_tax_of_tax_exemption_excise: + type: string + subtotal_with_tax_of_five_percent_excise: + type: string + subtotal_with_tax_of_eight_percent_excise: + type: string + subtotal_with_tax_of_eight_percent_as_reduced_tax_rate_excise: + type: string + subtotal_with_tax_of_ten_percent_excise: + type: string + total_price: + type: string + registration_code: + type: string + use_invoice_template: + type: boolean + config: + $ref: '#/components/schemas/BillingConfig' + required: + - id + - pdf_url + - operator_id + - department_id + - member_id + - member_name + - partner_id + - partner_name + - office_name + - office_detail + - title + - billing_date + - due_date + - created_at + - excise_price + - subtotal_price + - total_price + - use_invoice_template + Quote: + title: Quote + x-stoplight: + id: 1n11cz901hx4u + type: object + properties: + id: + type: string + pdf_url: + type: string + operator_id: + type: string + department_id: + type: string + member_id: + type: string + member_name: + type: string + partner_id: + type: string + partner_name: + type: string + partner_detail: + type: string + office_id: + type: string + office_name: + type: string + office_detail: + type: string + title: + type: string + memo: + type: string + quote_date: + type: string + format: date + example: "2023/08/24" + quote_number: + type: string + note: + type: string + expired_date: + type: string + format: date + example: "2023/08/24" + document_name: + type: string + order_status: + type: string + description: | + 受注ステータス: + * `failure` - 失注 + * `default` - 未設定 + * `not_received` - 未受注 + * `received` - 受注済み + enum: + - "failure" + - "default" + - "not_received" + - "received" + transmit_status: + type: string + description: | + メールステータス: + * `default` - 未設定 + * `sent` - 送付済み + * `already_read` - 受領済み + * `received` - 受信 + enum: + - default + - sent + - already_read + - received + posting_status: + type: string + description: | + 郵送ステータス: + * `default` - 未設定 + * `request` - 郵送依頼 + * `sent` - 郵送済み + * `cancel` - 郵送取消 + * `error` - 郵送失敗 + enum: + - default + - request + - sent + - cancel + - error + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + is_downloaded: + type: boolean + is_locked: + type: boolean + deduct_price: + type: string + description: 'Only return if my office type is individual ' + tag_names: + type: array + items: + type: string + items: + type: array + items: + $ref: '#/components/schemas/Item' + excise_price: + type: string + excise_price_of_untaxable: + type: string + excise_price_of_non_taxable: + type: string + excise_price_of_tax_exemption: + type: string + excise_price_of_five_percent: + type: string + excise_price_of_eight_percent: + type: string + excise_price_of_eight_percent_as_reduced_tax_rate: + type: string + excise_price_of_ten_percent: + type: string + subtotal_price: + type: string + subtotal_of_untaxable_excise: + type: string + subtotal_of_non_taxable_excise: + type: string + subtotal_of_tax_exemption_excise: + type: string + subtotal_of_five_percent_excise: + type: string + subtotal_of_eight_percent_excise: + type: string + subtotal_of_eight_percent_as_reduced_tax_rate_excise: + type: string + subtotal_of_ten_percent_excise: + type: string + total_price: + type: string + required: + - id + - pdf_url + - operator_id + - department_id + - member_id + - member_name + - partner_id + - partner_name + - office_name + - office_detail + - title + - quote_date + - expired_date + - created_at + - excise_price + - subtotal_price + - total_price + SentHistory: + title: SentHistory + x-stoplight: + id: lle67m3y8ul3v + type: object + properties: + id: + type: integer + type: + type: string + operator_id: + type: string + document_type: + type: string + document_id: + type: string + from: + type: string + to: + type: string + cc: + type: string + sender_name: + type: string + replay_to: + type: string + sent_at: + type: string + format: date-time + required: + - id + - type + - operator_id + - document_type + - document_id + - from + - to + - cc + - sender_name + - replay_to + - sent_at + PaginationData: + title: PaginationData + type: object + properties: + total_count: + type: number + total_pages: + type: number + per_page: + type: number + current_page: + type: number + required: + - total_count + - total_pages + - per_page + - current_page + requestBodies: + PartnerCreateRequest: + content: + application/json: + schema: + type: object + properties: + code: + type: string + maxLength: 30 + name: + type: string + maxLength: 100 + name_kana: + type: string + maxLength: 350 + name_suffix: + type: string + memo: + type: string + maxLength: 500 + departments: + description: 'Array of objects, maximum 10 items. Fill in at least one parameter' + type: array + items: + type: object + properties: + zip: + type: string + minLength: 7 + maxLength: 8 + pattern: '^\d{3}-?\d{4}$' + tel: + type: string + pattern: '/\A(\+|+)?[0-90-9\-|-|ー|−]+\s*+\z/' + minLength: 7 + maxLength: 30 + prefecture: + type: string + address1: + type: string + maxLength: 80 + address2: + type: string + maxLength: 80 + person_name: + type: string + maxLength: 35 + person_title: + type: string + maxLength: 35 + person_dept: + type: string + maxLength: 35 + office_member_name: + type: string + maxLength: 40 + email: + type: string + maxLength: 255 + cc_emails: + type: string + maxLength: 500 + peppol_id: + type: string + pattern: '/\A((0088|0188):[0-9]{13}|0221:T[0-9]{13})\z/' + payment_deadline_setting: + type: object + properties: + due_month: + type: string + description: | + 支払期日(月): + * `this_month` - 当月 + * `next_month` - 翌月 + * `two_months_after` - 2ヶ月後 + * `three_months_after` - 3ヶ月後 + * `four_months_after` - 4ヶ月後 + * `five_months_after` - 5ヶ月後 + * `six_months_after` - 6ヶ月後 + enum: + - this_month + - next_month + - two_months_after + - three_months_after + - four_months_after + - five_months_after + - six_months_after + due_date: + type: integer + description: | + Enter the payment due date. + If you want to set it to the last day of the month, set it to "-1". + If you want to set it to a specific day, set it to a number between 1 and 31. + Note: 0 is not a valid value. + If no corresponding day exists, the last day of the month will be automatically set. + minimum: -1 + maximum: 31 + contingency_day: + type: string + description: | + 支払期日が休日の場合の処理: + * `move_to_earlier_day` - 前倒し + * `keep_as_is` - そのまま + * `move_to_later_day` - 後倒し + enum: + - move_to_earlier_day + - keep_as_is + - move_to_later_day + required: + - due_month + - due_date + - contingency_day + required: + - name + description: Request body for creating a Partner + PartnerUpdateRequest: + content: + application/json: + schema: + type: object + properties: + code: + type: string + maxLength: 30 + name: + type: string + maxLength: 100 + name_kana: + type: string + maxLength: 350 + name_suffix: + type: string + memo: + type: string + maxLength: 500 + payment_deadline_setting: + type: object + properties: + due_month: + type: string + description: | + 支払期日(月): + * `this_month` - 当月 + * `next_month` - 翌月 + * `two_months_after` - 2ヶ月後 + * `three_months_after` - 3ヶ月後 + * `four_months_after` - 4ヶ月後 + * `five_months_after` - 5ヶ月後 + * `six_months_after` - 6ヶ月後 + enum: + - this_month + - next_month + - two_months_after + - three_months_after + - four_months_after + - five_months_after + - six_months_after + due_date: + type: integer + description: | + Enter the payment due date. + If you want to set it to the last day of the month, set it to "-1". + If you want to set it to a specific day, set it to a number between 1 and 31. + Note: 0 is not a valid value. + If no corresponding day exists, the last day of the month will be automatically set. + minimum: -1 + maximum: 31 + contingency_day: + type: string + description: | + 支払期日が休日の場合の処理: + * `move_to_earlier_day` - 前倒し + * `keep_as_is` - そのまま + * `move_to_later_day` - 後倒し + enum: + - move_to_earlier_day + - keep_as_is + - move_to_later_day + required: + - due_month + - due_date + - contingency_day + description: Request body for updating a Partner + OfficeUpdateRequest: + content: + application/json: + schema: + type: object + properties: + name: + type: string + maxLength: 35 + zip: + type: string + minLength: 7 + maxLength: 8 + pattern: '^\d{3}-?\d{4}$' + prefecture: + type: string + address1: + type: string + maxLength: 35 + address2: + type: string + maxLength: 35 + tel: + type: string + pattern: '/\A(\+|+)?[0-90-9\-|-|ー|−]+\s*+\z/' + minLength: 7 + maxLength: 30 + fax: + type: string + minLength: 7 + maxLength: 30 + pattern: '/\A(\+|+)?[0-90-9\-|-|ー|−]+\s*+\z/' + examples: {} + description: Request body for updating a Office + DepartmentCreateRequest: + content: + application/json: + schema: + description: 'Fill in at least one parameter.' + type: object + properties: + zip: + type: string + minLength: 7 + maxLength: 8 + pattern: '^\d{3}-?\d{4}$' + tel: + type: string + pattern: '/\A(\+|+)?[0-90-9\-|-|ー|−]+\s*+\z/' + minLength: 7 + maxLength: 30 + prefecture: + type: string + address1: + type: string + maxLength: 80 + address2: + type: string + maxLength: 80 + person_name: + type: string + maxLength: 35 + person_title: + type: string + maxLength: 35 + person_dept: + type: string + maxLength: 35 + office_member_name: + type: string + maxLength: 40 + email: + type: string + maxLength: 255 + cc_emails: + type: string + maxLength: 500 + peppol_id: + type: string + pattern: '/\A((0088|0188):[0-9]{13}|0221:T[0-9]{13})\z/' + + description: Request body for creating a department + DepartmentUpdateRequest: + content: + application/json: + schema: + type: object + properties: + zip: + type: string + minLength: 7 + maxLength: 8 + pattern: '^\d{3}-?\d{4}$' + tel: + type: string + pattern: '/\A(\+|+)?[0-90-9\-|-|ー|−]+\s*+\z/' + minLength: 7 + maxLength: 30 + prefecture: + type: string + address1: + type: string + maxLength: 80 + address2: + type: string + maxLength: 80 + person_name: + type: string + maxLength: 35 + person_title: + type: string + maxLength: 35 + person_dept: + type: string + maxLength: 35 + office_member_name: + type: string + maxLength: 40 + email: + type: string + maxLength: 255 + cc_emails: + type: string + maxLength: 500 + peppol_id: + type: string + pattern: '/\A((0088|0188):[0-9]{13}|0221:T[0-9]{13})\z/' + description: Request body for updating a department, Fill in at least one parameter. + ItemCreateRequest: + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 450 + code: + type: string + minLength: 1 + maxLength: 30 + detail: + type: string + maxLength: 200 + unit: + type: string + maxLength: 20 + price: + type: number + minimum: '-10_000_000_000' + maximum: '10_000_000_000' + quantity: + type: number + minimum: '-10_000_000_000' + maximum: '10_000_000_000' + is_deduct_withholding_tax: + type: boolean + description: | + 源泉徴収税額の有り無し: + * 事業者が法人の時: null + * 事業者が個人事業主: true or false + excise: + type: string + description: | + 税率: + * `untaxable` - 不課税 + * `non_taxable` - 非課税 + * `tax_exemption` - 免税 + * `five_percent` - 5% + * `eight_percent` - 8% + * `eight_percent_as_reduced_tax_rate` - 8%(軽減税率) + * `ten_percent` - 10% + enum: + - untaxable + - non_taxable + - tax_exemption + - five_percent + - eight_percent + - eight_percent_as_reduced_tax_rate + - ten_percent + required: + - name + - excise + examples: + Example: + $ref: '#/components/examples/IvItemRequestExample' + description: Request body for creating a Item + ItemUpdateRequest: + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 450 + code: + type: string + minLength: 1 + maxLength: 30 + detail: + type: string + maxLength: 200 + unit: + type: string + maxLength: 20 + price: + type: number + minimum: '-10_000_000_000' + maximum: '10_000_000_000' + quantity: + type: number + minimum: '-10_000_000_000' + maximum: '10_000_000_000' + is_deduct_withholding_tax: + type: boolean + description: | + 源泉徴収税額の有り無し: + * 事業者が法人の時: null + * 事業者が個人事業主: true or false + excise: + type: string + description: | + 税率: + * `untaxable` - 不課税 + * `non_taxable` - 非課税 + * `tax_exemption` - 免税 + * `five_percent` - 5% + * `eight_percent` - 8% + * `eight_percent_as_reduced_tax_rate` - 8%(軽減税率) + * `ten_percent` - 10% + enum: + - untaxable + - non_taxable + - tax_exemption + - five_percent + - eight_percent + - eight_percent_as_reduced_tax_rate + - ten_percent + examples: + Example: + $ref: '#/components/examples/IvItemRequestExample' + description: Request body for updating a Item + BillingCreateRequest: + content: + application/json: + schema: + type: object + properties: + department_id: + type: string + title: + type: string + maxLength: 200 + memo: + type: string + maxLength: 450 + payment_condition: + type: string + maxLength: 250 + billing_date: + type: string + format: date + example: '2022-12-09' + due_date: + type: string + format: date + example: '2022-12-10' + sales_date: + type: string + format: date + example: '2022-12-09' + billing_number: + type: string + maxLength: 30 + note: + type: string + maxLength: 2000 + document_name: + type: string + maxLength: 25 + tag_names: + type: array + items: + type: string + maxLength: 255 + required: + - department_id + - billing_date + - due_date + description: Request body for creating a billing + BillingUpdateRequest: + content: + application/json: + schema: + type: object + properties: + department_id: + type: string + title: + type: string + minLength: 1 + maxLength: 200 + memo: + type: string + maxLength: 450 + payment_condition: + type: string + maxLength: 250 + billing_date: + type: string + format: date + example: '2022-12-09' + due_date: + type: string + format: date + example: '2022-12-10' + sales_date: + type: string + format: date + example: '2022-12-09' + billing_number: + type: string + maxLength: 30 + note: + type: string + maxLength: 2000 + document_name: + type: string + maxLength: 25 + tag_names: + type: array + items: + type: string + maxLength: 255 + description: Request body for updating a billing + BillingNewTemplateCreateRequest: + content: + application/json: + schema: + type: object + properties: + department_id: + type: string + title: + type: string + maxLength: 200 + memo: + type: string + maxLength: 450 + payment_condition: + type: string + maxLength: 250 + billing_date: + type: string + format: date + due_date: + type: string + format: date + description: | + If due_date is blank and the customer has a payment due date, the payment due date will be set based on billing_date. + If due_date is blank and the customer does not have a payment due date, an error will occur. + sales_date: + type: string + format: date + billing_number: + type: string + maxLength: 30 + note: + type: string + maxLength: 2000 + document_name: + type: string + maxLength: 25 + tag_names: + type: array + items: + type: string + maxLength: 255 + items: + type: array + description: '`item_id`を指定しない場合、`excise`は必須となります。' + items: + type: object + properties: + item_id: + type: string + name: + type: string + description: '`item_id`を指定した場合は、こちらの`name`を指定しても、`item_id`に紐づいたマスタitemのnameで登録します。' + minLength: 1 + maxLength: 450 + delivery_number: + type: string + maxLength: 30 + delivery_date: + type: string + format: date + detail: + type: string + maxLength: 200 + unit: + type: string + maxLength: 20 + price: + type: number + minimum: '-10_000_000_000' + maximum: '10_000_000_000' + quantity: + type: number + minimum: '-10_000_000_000' + maximum: '10_000_000_000' + is_deduct_withholding_tax: + type: boolean + description: | + 源泉徴収税額の有り無し: + * 事業者が法人の時: null + * 事業者が個人事業主: true or false + excise: + type: string + enum: + - untaxable + - non_taxable + - tax_exemption + - five_percent + - eight_percent + - eight_percent_as_reduced_tax_rate + - ten_percent + required: + - department_id + - billing_date + examples: + Example: + value: + department_id: string + title: string + memo: string + payment_condition: string + billing_date: '2019-08-24' + due_date: '2019-08-24' + sales_date: '2019-08-24' + billing_number: string + note: string + document_name: string + tag_names: + - string + items: + - item_id: string + delivery_number: 1234Num567 + delivery_date: 2023/06/07 + name: string + detail: string + unit: string + price: 10 + quantity: 10 + is_deduct_withholding_tax: false + excise: untaxable + description: Request body for creating a billing + PaymentStatusUpdateRequest: + content: + application/json: + schema: + type: object + required: + - payment_status + properties: + payment_status: + type: string + description: | + 入金ステータス:: + * `0` - 未設定 + * `1` - 未入金 + * `2` - 入金済み + enum: + - "0" + - "1" + - "2" + QuoteCreateRequest: + content: + application/json: + schema: + type: object + properties: + department_id: + type: string + quote_number: + type: string + maxLength: 30 + title: + type: string + maxLength: 200 + memo: + type: string + maxLength: 450 + quote_date: + type: string + format: date + example: '2022-12-09' + expired_date: + type: string + format: date + example: '2022-12-10' + note: + type: string + maxLength: 2000 + tag_names: + type: array + items: + type: string + maxLength: 255 + document_name: + type: string + maxLength: 25 + items: + type: array + description: '`item_id`を指定しない場合、`excise`は必須となります。' + items: + type: object + properties: + item_id: + type: string + name: + type: string + description: '`item_id`を指定した場合は、こちらの`name`を指定しても、`item_id`に紐づいたマスタitemのnameで登録します。' + minLength: 1 + maxLength: 450 + detail: + type: string + maxLength: 200 + unit: + type: string + maxLength: 20 + price: + type: number + minimum: '-10_000_000_000' + maximum: '10_000_000_000' + quantity: + type: number + minimum: '-10_000_000_000' + maximum: '10_000_000_000' + is_deduct_withholding_tax: + type: boolean + description: | + 源泉徴収税額の有り無し: + * 事業者が法人の時: null + * 事業者が個人事業主: true or false + excise: + type: string + enum: + - untaxable + - non_taxable + - tax_exemption + - five_percent + - eight_percent + - eight_percent_as_reduced_tax_rate + - ten_percent + required: + - department_id + - quote_date + - expired_date + examples: + Example: + value: + department_id: string + quote_number: string + title: string + memo: string + quote_date: '2022-12-09' + expired_date: '2022-12-10' + note: string + tag_names: + - string + items: + - item_id: string + name: string + detail: string + unit: string + price: 10 + quantity: 10 + is_deduct_withholding_tax: false + excise: untaxable + document_name: string + description: Request body for creating a quote + QuoteUpdateRequest: + content: + application/json: + schema: + type: object + properties: + department_id: + type: string + quote_number: + type: string + maxLength: 30 + title: + type: string + minLength: 1 + maxLength: 200 + memo: + type: string + maxLength: 450 + quote_date: + type: string + format: date + example: '2022-12-09' + expired_date: + type: string + format: date + example: '2022-12-10' + note: + type: string + maxLength: 2000 + tag_names: + type: array + items: + type: string + maxLength: 255 + document_name: + type: string + maxLength: 25 + description: Request body for updating a quote + QuoteItemAttachRequest: + content: + application/json: + schema: + type: object + description: '`item_id`を指定しない場合、`excise`は必須となります。' + properties: + item_id: + type: string + name: + type: string + description: '`item_id`を指定した場合は、こちらの`name`を指定しても、`item_id`に紐づいたマスタitemのnameで登録します。' + minLength: 1 + maxLength: 450 + detail: + type: string + maxLength: 200 + unit: + type: string + maxLength: 20 + price: + type: number + minimum: '-10_000_000_000' + maximum: '10_000_000_000' + quantity: + type: number + minimum: '-10_000_000_000' + maximum: '10_000_000_000' + is_deduct_withholding_tax: + type: boolean + description: | + 源泉徴収税額の有り無し: + * 事業者が法人の時: null + * 事業者が個人事業主: true or false + excise: + type: string + enum: + - untaxable + - non_taxable + - tax_exemption + - five_percent + - eight_percent + - eight_percent_as_reduced_tax_rate + - ten_percent + BillingItemAttachRequest: + content: + application/json: + schema: + type: object + description: '`item_id`を指定しない場合、`excise`は必須となります。' + properties: + item_id: + type: string + name: + type: string + description: '`item_id`を指定した場合は、こちらの`name`を指定しても、`item_id`に紐づいたマスタitemのnameで登録します。' + minLength: 1 + maxLength: 450 + delivery_number: + type: string + maxLength: 30 + delivery_date: + type: string + format: date + detail: + type: string + maxLength: 200 + unit: + type: string + maxLength: 20 + price: + type: number + minimum: '-10_000_000_000' + maximum: '10_000_000_000' + quantity: + type: number + minimum: '-10_000_000_000' + maximum: '10_000_000_000' + is_deduct_withholding_tax: + type: boolean + description: | + 源泉徴収税額の有り無し: + * 事業者が法人の時: null + * 事業者が個人事業主: true or false + excise: + type: string + enum: + - untaxable + - non_taxable + - tax_exemption + - five_percent + - eight_percent + - eight_percent_as_reduced_tax_rate + - ten_percent + securitySchemes: + AccessToken: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: 'https://api.biz.moneyforward.com/authorize' + tokenUrl: 'https://api.biz.moneyforward.com/token' + scopes: + mfc/invoice/data.write: Grant read and write access to all your office's data + mfc/invoice/data.read: Grant read-only access to all your office's data + refreshUrl: 'https://api.biz.moneyforward.com/token' + parameters: {} + examples: + CorporationOffice: + value: + id: tZ7wyN9WVuTy7nsisjGsjA + name: My Office Corporation + zip: '1234567' + prefecture: 北海道 + address1: Address 1 + address2: Address 2 + tel: 03-1234-5678 + fax: 03-1234-5678 + office_type: 法人 + office_code: 1102-9829 + created_at: '2022-07-14 13:10:10 +0900' + updated_at: '2023-03-20 12:48:33 +0900' + registration_code: T1234567891234 + IndividualOffice: + value: + id: tZ7wyN9WVuTy7nsisjGsjA + name: My Office Individual + zip: '1234567' + prefecture: 北海道 + address1: Address 1 + address2: Address 2 + tel: 03-1234-5678 + fax: 03-1234-5678 + office_type: 個人 + office_code: 1103-9829 + created_at: '2022-07-14 13:10:10 +0900' + updated_at: '2023-03-20 12:48:33 +0900' + registration_code: T1234567891234 + IvItemWithoutIsDeductWithHoldingTax: + value: + id: t93_uqoFUT_EnX85CJ16XA + name: Name + code: Code + detail: Detal + unit: Unit + price: '1239.1' + quantity: '1' + excise: ten_percent + created_at: '2022-07-14 13:14:03 +0900' + updated_at: '2023-01-27 16:19:56 +0900' + PartnerWithoutDepartment: + value: + id: 95PHKI9_FeSw3coTj673Cg + code: p41uz1dyvw3cj71qrkja + name: Ryu p41uz1dyvw3cj71qrkja + name_kana: p41uz1dyvw3cj71qrkja + name_suffix: 御中 + memo: p41uz1dyvw3cj71qrkja + created_at: '2023-03-20 13:39:28 +0900' + updated_at: '2023-03-20 13:39:28 +0900' + departments: [] + payment_deadline_setting: null + PartnerWithDepartment: + value: + id: 95PHKI9_FeSw3coTj673Cg + code: p41uz1dyvw3cj71qrkja + name: Ryu p41uz1dyvw3cj71qrkja + name_kana: p41uz1dyvw3cj71qrkja + name_suffix: 御中 + memo: p41uz1dyvw3cj71qrkja + created_at: '2023-03-20 13:39:28 +0900' + updated_at: '2023-03-20 13:39:28 +0900' + departments: + - id: qwc4iT7ZrywxipJCOqtZQg + zip: 123-4567 + tel: '1234567' + prefecture: 山形県 + address1: hb3m8kaxz9eex1czmpn2 + address2: hb3m8kaxz9eex1czmpn2 + person_name: hb3m8kaxz9eex1czmpn2 + person_title: hb3m8kaxz9eex1czmpn2 + person_dept: hb3m8kaxz9eex1czmpn2 + email: hb3m8kaxz9eex1czmpn2@moneyforward.com + cc_emails: hb3m8kaxz9eex1czmpn2@moneyforward.com + peppol_id: 0088:0000000000001 + office_member_name: hb3m8kaxz9eex1czmpn2 + office_member_id: '-UNhHGbLKnWH5xlrFhj2ow' + created_at: '2023-03-20 13:44:52 +0900' + updated_at: '2023-03-20 13:44:52 +0900' + payment_deadline_setting: + due_month: next_month + due_date: 31 + contingency_day: keep_as_is + Department: + value: + id: qwc4iT7ZrywxipJCOqtZQg + zip: 123-4567 + tel: '1234567' + prefecture: 山形県 + address1: hb3m8kaxz9eex1czmpn2 + address2: hb3m8kaxz9eex1czmpn2 + person_name: hb3m8kaxz9eex1czmpn2 + person_title: hb3m8kaxz9eex1czmpn2 + person_dept: hb3m8kaxz9eex1czmpn2 + email: hb3m8kaxz9eex1czmpn2@moneyforward.com + cc_emails: hb3m8kaxz9eex1czmpn2@moneyforward.com + peppol_id: 0088:0000000000001 + office_member_name: hb3m8kaxz9eex1czmpn2 + office_member_id: '-UNhHGbLKnWH5xlrFhj2ow' + created_at: '2023-03-20 13:44:52 +0900' + updated_at: '2023-03-20 13:44:52 +0900' + IvItemWithIsDeductWithHoldingTax: + value: + id: t93_uqoFUT_EnX85CJ16XA + name: Name + code: Code + detail: Detal + unit: Unit + price: '1239.1' + quantity: '1' + excise: ten_percent + is_deduct_withholding_tax: true + created_at: '2022-07-14 13:14:03 +0900' + updated_at: '2023-01-27 16:19:56 +0900' + OldBillingWithoutItemsAndDeductPrice: + value: + id: 4k1zMaonOMw9FKd5xWNqJg + pdf_url: 'https://invoice.moneyforward.com/api/v3/billings/4k1zMaonOMw9FKd5xWNqJg.pdf' + operator_id: fbeo9WVrdW36B1CKP3KASg + department_id: qwc4iT7ZrywxipJCOqtZQg + member_id: '-UNhHGbLKnWH5xlrFhj2ow' + member_name: hb3m8kaxz9eex1czmpn2 + partner_id: 95PHKI9_FeSw3coTj673Cg + partner_name: Name + office_id: tZ7wyN9WVuTy7nsisjGsjA + office_name: My Office Corporation + office_detail: | + 〒123-4567 + 北海道Address 1 + Address 2 + TEL: 03-1234-5678 + FAX: 03-1234-5678 + title: create billing_v3vcbglozq + memo: memo_v3vcbglozq + payment_condition: payment_condition_v3vcbglozq + billing_date: '2022/12/09' + due_date: '2022/12/10' + sales_date: '2022/12/09' + billing_number: billing num_v3vcbglozq + note: note_v3vcbglozq + document_name: doc name_v3vcbglozq + payment_status: 未設定 + email_status: 未送信 + posting_status: 未郵送 + created_at: '2023-03-20 14:56:10 +0900' + updated_at: '2023-03-20 14:56:10 +0900' + is_downloaded: false + is_locked: false + tag_names: + - tag_v3vcbglozq + items: [] + excise_price: '0.0' + excise_price_of_untaxable: '0.0' + excise_price_of_non_taxable: '0.0' + excise_price_of_tax_exemption: '0.0' + excise_price_of_five_percent: '0.0' + excise_price_of_eight_percent: '0.0' + excise_price_of_eight_percent_as_reduced_tax_rate: '0.0' + excise_price_of_ten_percent: '0.0' + subtotal_price: '0.0' + subtotal_of_untaxable_excise: '0.0' + subtotal_of_non_taxable_excise: '0.0' + subtotal_of_tax_exemption_excise: '0.0' + subtotal_of_five_percent_excise: '0.0' + subtotal_of_eight_percent_excise: '0.0' + subtotal_of_eight_percent_as_reduced_tax_rate_excise: '0.0' + subtotal_of_ten_percent_excise: '0.0' + subtotal_with_tax_of_untaxable_excise: '0.0' + subtotal_with_tax_of_non_taxable_excise: '0.0' + subtotal_with_tax_of_tax_exemption_excise: '0.0' + subtotal_with_tax_of_five_percent_excise: '0.0' + subtotal_with_tax_of_eight_percent_excise: '0.0' + subtotal_with_tax_of_eight_percent_as_reduced_tax_rate_excise: '0.0' + subtotal_with_tax_of_ten_percent_excise: '0.0' + total_price: '0.0' + registration_code: null + use_invoice_template: false + config: + rounding: round_down + rounding_consumption_tax: round_up + consumption_tax_display_type: internal + OldBillingWithoutItemsAndHasDeductPrice: + value: + id: 4k1zMaonOMw9FKd5xWNqJg + pdf_url: 'https://invoice.moneyforward.com/api/v3/billings/4k1zMaonOMw9FKd5xWNqJg.pdf' + operator_id: fbeo9WVrdW36B1CKP3KASg + department_id: qwc4iT7ZrywxipJCOqtZQg + member_id: '-UNhHGbLKnWH5xlrFhj2ow' + member_name: hb3m8kaxz9eex1czmpn2 + partner_id: 95PHKI9_FeSw3coTj673Cg + partner_name: p41uz1dyvw3cj71qrkja + office_id: tZ7wyN9WVuTy7nsisjGsjA + office_name: My Office Individual + office_detail: | + 〒123-4567 + 北海道Address 1 + Address 2 + TEL: 03-1234-5678 + FAX: 03-1234-5678 + title: create billing_v3vcbglozq + memo: memo_v3vcbglozq + payment_condition: payment_condition_v3vcbglozq + billing_date: '2022/12/09' + due_date: '2022/12/10' + sales_date: '2022/12/09' + billing_number: billing num_v3vcbglozq + note: note_v3vcbglozq + document_name: doc name_v3vcbglozq + payment_status: 未設定 + email_status: 未送信 + posting_status: 未郵送 + created_at: '2023-03-20 14:56:10 +0900' + updated_at: '2023-03-20 14:56:10 +0900' + is_downloaded: false + is_locked: false + deduct_price: '0.0' + tag_names: + - tag_v3vcbglozq + items: [] + excise_price: '0.0' + excise_price_of_untaxable: '0.0' + excise_price_of_non_taxable: '0.0' + excise_price_of_tax_exemption: '0.0' + excise_price_of_five_percent: '0.0' + excise_price_of_eight_percent: '0.0' + excise_price_of_eight_percent_as_reduced_tax_rate: '0.0' + excise_price_of_ten_percent: '0.0' + subtotal_price: '0.0' + subtotal_of_untaxable_excise: '0.0' + subtotal_of_non_taxable_excise: '0.0' + subtotal_of_tax_exemption_excise: '0.0' + subtotal_of_five_percent_excise: '0.0' + subtotal_of_eight_percent_excise: '0.0' + subtotal_of_eight_percent_as_reduced_tax_rate_excise: '0.0' + subtotal_of_ten_percent_excise: '0.0' + subtotal_with_tax_of_untaxable_excise: '0.0' + subtotal_with_tax_of_non_taxable_excise: '0.0' + subtotal_with_tax_of_tax_exemption_excise: '0.0' + subtotal_with_tax_of_five_percent_excise: '0.0' + subtotal_with_tax_of_eight_percent_excise: '0.0' + subtotal_with_tax_of_eight_percent_as_reduced_tax_rate_excise: '0.0' + subtotal_with_tax_of_ten_percent_excise: '0.0' + total_price: '0.0' + registration_code: null + use_invoice_template: false + QuoteWithoutDeductPrice: + value: + id: OfEG-jR-EH4gZoBfDcz1xg + pdf_url: 'https:/invoice.moneyforward.com/api/v3/quotes/OfEG-jR-EH4gZoBfDcz1xg.pdf' + operator_id: fbeo9WVrdW36B1CKP3KASg + department_id: qwc4iT7ZrywxipJCOqtZQg + member_id: '-UNhHGbLKnWH5xlrFhj2ow' + member_name: hb3m8kaxz9eex1czmpn2 + partner_id: 95PHKI9_FeSw3coTj673Cg + partner_name: p41uz1dyvw3cj71qrkja + partner_detail: |- + 〒123-4567 + 山形県hb3m8kaxz9eex1czmpn2 + hb3m8kaxz9eex1czmpn2 + hb3m8kaxz9eex1czmpn2 + hb3m8kaxz9eex1czmpn2 + hb3m8kaxz9eex1czmpn2様 + office_id: tZ7wyN9WVuTy7nsisjGsjA + office_name: My Office Corporation + office_detail: | + 〒123-4567 + 北海道Address 1 + Address 2 + TEL: 03-1234-5678 + FAX: 03-1234-5678 + title: title_149fedb5bq + memo: memo_149fedb5bq + quote_date: 2022/12/01 + quote_number: quote num_149fedb5bq + note: note_149fedb5bq + expired_date: 2023/12/30 + document_name: 見積書 + order_status: default + transmit_status: default + posting_status: default + created_at: '2023-03-20 15:56:27 +0900' + updated_at: '2023-03-20 15:56:27 +0900' + is_downloaded: false + is_locked: false + tag_names: + - tags + items: + - id: Z12BKLtb0x4IoBHTDY4y5Q + name: name_0snq9xx1mv + code: code_0snq9xx1mv + detail: detail_0snq9xx1mv + unit: unit_0snq9xx1mv + price: '10' + quantity: '10' + excise: untaxable + created_at: '2023-06-07 16:00:19 +0900' + updated_at: '2023-06-07 16:00:19 +0900' + excise_price: '0.0' + excise_price_of_untaxable: '0.0' + excise_price_of_non_taxable: '0.0' + excise_price_of_tax_exemption: '0.0' + excise_price_of_five_percent: '0.0' + excise_price_of_eight_percent: '0.0' + excise_price_of_eight_percent_as_reduced_tax_rate: '0.0' + excise_price_of_ten_percent: '0.0' + subtotal_price: '100.0' + subtotal_of_untaxable_excise: '100.0' + subtotal_of_non_taxable_excise: '0.0' + subtotal_of_tax_exemption_excise: '0.0' + subtotal_of_five_percent_excise: '0.0' + subtotal_of_eight_percent_excise: '0.0' + subtotal_of_eight_percent_as_reduced_tax_rate_excise: '0.0' + subtotal_of_ten_percent_excise: '0.0' + subtotal_with_tax_of_untaxable_excise: '100.0' + subtotal_with_tax_of_non_taxable_excise: '0.0' + subtotal_with_tax_of_five_percent_excise: '0.0' + subtotal_with_tax_of_tax_exemption_excise: '0.0' + subtotal_with_tax_of_eight_percent_excise: '0.0' + subtotal_with_tax_of_eight_percent_as_reduced_tax_rate_excise: '0.0' + subtotal_with_tax_of_ten_percent_excise: '100.0' + total_price: '100.0' + QuoteWithDeductPrice: + value: + id: OfEG-jR-EH4gZoBfDcz1xg + pdf_url: 'https:/invoice.moneyforward.com/api/v3/quotes/OfEG-jR-EH4gZoBfDcz1xg.pdf' + operator_id: fbeo9WVrdW36B1CKP3KASg + department_id: qwc4iT7ZrywxipJCOqtZQg + member_id: '-UNhHGbLKnWH5xlrFhj2ow' + member_name: hb3m8kaxz9eex1czmpn2 + partner_id: 95PHKI9_FeSw3coTj673Cg + partner_name: p41uz1dyvw3cj71qrkja + partner_detail: |- + 〒123-4567 + 山形県hb3m8kaxz9eex1czmpn2 + hb3m8kaxz9eex1czmpn2 + hb3m8kaxz9eex1czmpn2 + hb3m8kaxz9eex1czmpn2 + hb3m8kaxz9eex1czmpn2様 + office_id: tZ7wyN9WVuTy7nsisjGsjA + office_name: My Office Individual + office_detail: | + 〒123-4567 + 北海道Address 1 + Address 2 + TEL: 03-1234-5678 + FAX: 03-1234-5678 + title: title_149fedb5bq + memo: memo_149fedb5bq + quote_date: 2022/12/01 + quote_number: quote num_149fedb5bq + note: note_149fedb5bq + expired_date: 2023/12/30 + document_name: 見積書 + order_status: default + transmit_status: default + posting_status: default + created_at: '2023-03-20 15:56:27 +0900' + updated_at: '2023-03-20 15:56:27 +0900' + is_downloaded: false + is_locked: false + tag_names: + - tag + items: + - id: Z12BKLtb0x4IoBHTDY4y5Q + name: name_0snq9xx1mv + code: code_0snq9xx1mv + detail: detail_0snq9xx1mv + unit: unit_0snq9xx1mv + is_deduct_withholding_tax: false + price: '10' + quantity: '10' + excise: untaxable + created_at: '2023-06-07 16:00:19 +0900' + updated_at: '2023-06-07 16:00:19 +0900' + excise_price: '0.0' + excise_price_of_untaxable: '0.0' + excise_price_of_non_taxable: '0.0' + excise_price_of_tax_exemption: '0.0' + excise_price_of_five_percent: '0.0' + excise_price_of_eight_percent: '0.0' + excise_price_of_eight_percent_as_reduced_tax_rate: '0.0' + excise_price_of_ten_percent: '0.0' + subtotal_price: '0.0' + subtotal_of_untaxable_excise: '0.0' + subtotal_of_non_taxable_excise: '0.0' + subtotal_of_tax_exemption_excise: '0.0' + subtotal_of_five_percent_excise: '0.0' + subtotal_of_eight_percent_excise: '0.0' + subtotal_of_eight_percent_as_reduced_tax_rate_excise: '0.0' + subtotal_of_ten_percent_excise: '0.0' + subtotal_with_tax_of_untaxable_excise: '100.0' + subtotal_with_tax_of_non_taxable_excise: '0.0' + subtotal_with_tax_of_five_percent_excise: '0.0' + subtotal_with_tax_of_tax_exemption_excise: '0.0' + subtotal_with_tax_of_eight_percent_excise: '0.0' + subtotal_with_tax_of_eight_percent_as_reduced_tax_rate_excise: '0.0' + subtotal_with_tax_of_ten_percent_excise: '100.0' + total_price: '100.0' + deduct_price: '0.0' + NewBillingWithDeductPrice: + value: + id: 4k1zMaonOMw9FKd5xWNqJL + pdf_url: 'https://invoice.moneyforward.com/api/v3/billings/4k1zMaonOMw9FKd5xWNqJL.pdf' + operator_id: fbeo9WVrdW36B1CKP3KASg + department_id: qwc4iT7ZrywxipJCOqtZQg + member_id: '-UNhHGbLKnWH5xlrFhj2ow' + member_name: hb3m8kaxz9eex1czmpn2 + partner_id: 95PHKI9_FeSw3coTj673Cg + partner_name: p41uz1dyvw3cj71qrkja + office_id: tZ7wyN9WVuTy7nsisjGsjA + office_name: My Office Individual + office_detail: | + 〒123-4567 + 北海道Address 1 + Address 2 + TEL: 03-1234-5678 + FAX: 03-1234-5678 + title: create billing_v3vcbglozq + memo: memo_v3vcbglozq + payment_condition: payment_condition_v3vcbglozq + billing_date: '2022/12/09' + due_date: '2022/12/10' + sales_date: '2022/12/09' + billing_number: billing num_v3vcbglozq + note: note_v3vcbglozq + document_name: doc name_v3vcbglozq + payment_status: 未設定 + email_status: 未送信 + posting_status: 未郵送 + created_at: '2023-03-20 14:56:10 +0900' + updated_at: '2023-03-20 14:56:10 +0900' + is_downloaded: false + is_locked: false + deduct_price: '0.0' + tag_names: + - tag_v3vcbglozq + items: + - id: Z12BKLtb0x4IoBHTDY4y5Q + name: name_0snq9xx1mv + code: code_0snq9xx1mv + detail: detail_0snq9xx1mv + unit: unit_0snq9xx1mv + price: '10' + quantity: '10' + excise: untaxable + delivery_date: 2023/06/07 + delivery_number: 1234Num567 + is_deduct_withholding_tax: false + created_at: '2023-06-07 16:00:19 +0900' + updated_at: '2023-06-07 16:00:19 +0900' + excise_price: '0.0' + excise_price_of_untaxable: '0.0' + excise_price_of_non_taxable: '0.0' + excise_price_of_tax_exemption: '0.0' + excise_price_of_five_percent: '0.0' + excise_price_of_eight_percent: '0.0' + excise_price_of_eight_percent_as_reduced_tax_rate: '0.0' + excise_price_of_ten_percent: '0.0' + subtotal_price: '100.0' + subtotal_of_untaxable_excise: '100.0' + subtotal_of_non_taxable_excise: '0.0' + subtotal_of_tax_exemption_excise: '0.0' + subtotal_of_five_percent_excise: '0.0' + subtotal_of_eight_percent_excise: '0.0' + subtotal_of_eight_percent_as_reduced_tax_rate_excise: '0.0' + subtotal_of_ten_percent_excise: '0.0' + subtotal_with_tax_of_untaxable_excise: '100.0' + subtotal_with_tax_of_non_taxable_excise: '0.0' + subtotal_with_tax_of_five_percent_excise: '0.0' + subtotal_with_tax_of_tax_exemption_excise: '0.0' + subtotal_with_tax_of_eight_percent_excise: '0.0' + subtotal_with_tax_of_eight_percent_as_reduced_tax_rate_excise: '0.0' + subtotal_with_tax_of_ten_percent_excise: '100.0' + total_price: '100.0' + registration_code: T1234567890812 + use_invoice_template: true + NewBillingWithoutDeductPrice: + value: + id: 4k1zMaonOMw9FKd5xWNqJK + pdf_url: 'https://invoice.moneyforward.com/api/v3/billings/4k1zMaonOMw9FKd5xWNqJK.pdf' + operator_id: fbeo9WVrdW36B1CKP3KASg + department_id: qwc4iT7ZrywxipJCOqtZQg + member_id: '-UNhHGbLKnWH5xlrFhj2ow' + member_name: hb3m8kaxz9eex1czmpn2 + partner_id: 95PHKI9_FeSw3coTj673Cg + partner_name: Name + office_id: tZ7wyN9WVuTy7nsisjGsjA + office_name: My Office Corporation + office_detail: | + 〒123-4567 + 北海道Address 1 + Address 2 + TEL: 03-1234-5678 + FAX: 03-1234-5678 + title: create billing_v3vcbglozq + memo: memo_v3vcbglozq + payment_condition: payment_condition_v3vcbglozq + billing_date: '2022/12/09' + due_date: '2022/12/10' + sales_date: '2022/12/09' + billing_number: billing num_v3vcbglozq + note: note_v3vcbglozq + document_name: doc name_v3vcbglozq + payment_status: 未設定 + email_status: 未送信 + posting_status: 未郵送 + created_at: '2023-03-20 14:56:10 +0900' + updated_at: '2023-03-20 14:56:10 +0900' + is_downloaded: false + is_locked: false + tag_names: + - tag_v3vcbglozq + items: + - id: Z12BKLtb0x4IoBHTDY4y5Q + name: name_0snq9xx1mv + code: code_0snq9xx1mv + detail: detail_0snq9xx1mv + unit: unit_0snq9xx1mv + price: '10' + quantity: '10' + excise: untaxable + delivery_date: 2023/06/07 + delivery_number: 1234Num567 + created_at: '2023-06-07 16:00:19 +0900' + updated_at: '2023-06-07 16:00:19 +0900' + excise_price: '0.0' + excise_price_of_untaxable: '0.0' + excise_price_of_non_taxable: '0.0' + excise_price_of_tax_exemption: '0.0' + excise_price_of_five_percent: '0.0' + excise_price_of_eight_percent: '0.0' + excise_price_of_eight_percent_as_reduced_tax_rate: '0.0' + excise_price_of_ten_percent: '0.0' + subtotal_price: '100.0' + subtotal_of_untaxable_excise: '100.0' + subtotal_of_non_taxable_excise: '0.0' + subtotal_of_tax_exemption_excise: '0.0' + subtotal_of_five_percent_excise: '0.0' + subtotal_of_eight_percent_excise: '0.0' + subtotal_of_eight_percent_as_reduced_tax_rate_excise: '0.0' + subtotal_of_ten_percent_excise: '0.0' + subtotal_with_tax_of_untaxable_excise: '100.0' + subtotal_with_tax_of_non_taxable_excise: '0.0' + subtotal_with_tax_of_five_percent_excise: '0.0' + subtotal_with_tax_of_tax_exemption_excise: '0.0' + subtotal_with_tax_of_eight_percent_excise: '0.0' + subtotal_with_tax_of_eight_percent_as_reduced_tax_rate_excise: '0.0' + subtotal_with_tax_of_ten_percent_excise: '100.0' + total_price: '100.0' + registration_code: T1234567890812 + use_invoice_template: true + config: + rounding: round_down + rounding_consumption_tax: round_up + consumption_tax_display_type: internal + IvItemRequestExample: + value: + name: string + code: string + detail: string + unit: string + price: 10000000 + quantity: 10000000 + is_deduct_withholding_tax: true + excise: untaxable +security: + - AccessToken: + - mfc/invoice/data.write + - mfc/invoice/data.read diff --git a/main.go b/main.go index decf156..d01eed4 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,12 @@ package main import "github.com/beatinaniwa/mf-cli/cmd" func main() { - cmd.EmbeddedSpec = embeddedSpec + cmd.EmbeddedSpecs = map[string][]byte{ + "accounting": embeddedAccountingSpec, + "invoice": embeddedInvoiceSpec, + } + // Backwards-compatible alias for any code path still reading the + // accounting spec via the legacy single-variable name. + cmd.EmbeddedSpec = embeddedAccountingSpec cmd.Execute() } diff --git a/specembed.go b/specembed.go index fcbfd78..53ecb67 100644 --- a/specembed.go +++ b/specembed.go @@ -3,4 +3,7 @@ package main import _ "embed" //go:embed mf_openapi.yaml -var embeddedSpec []byte +var embeddedAccountingSpec []byte + +//go:embed iv_openapi.yaml +var embeddedInvoiceSpec []byte