├── .github ├── dependabot.yml ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── documentation.yml │ ├── bug_report.yml │ └── feature_request.yml └── workflows │ ├── release.yml │ └── ci.yml ├── go.mod ├── testdata ├── fixtures │ ├── payment.json │ └── payments_list.json └── README.md ├── docs ├── README.md ├── getting-started.md ├── authentication.md ├── error-handling.md └── api-reference.md ├── .gitignore ├── LICENSE ├── examples ├── basic │ ├── README.md │ └── main.go ├── webhooks │ ├── README.md │ └── main.go └── invoices │ └── main.go ├── CHANGELOG.md ├── .golangci.yml ├── Makefile ├── errors.go ├── logo.svg ├── client_test.go ├── assets └── banner.svg ├── payment_terms.go ├── files_test.go ├── CONTRIBUTING.md ├── errors_test.go ├── payment_terms_test.go ├── CODE_OF_CONDUCT.md ├── files.go ├── invoices.go ├── SECURITY.md ├── payments.go ├── client.go ├── clients.go ├── webhooks.go ├── webhooks_test.go ├── credits_test.go ├── credits.go ├── retry.go ├── retry_test.go ├── example_test.go ├── integration_test.go └── invoices_test.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: gomod 5 | schedule: 6 | interval: "daily" 7 | directory: / 8 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [AshkanYarmoradi] 4 | custom: 5 | - https://www.buymeacoffee.com/ayarmoradi 6 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/AshkanYarmoradi/go-invoice-ninja 2 | 3 | go 1.21 4 | 5 | // This SDK provides a Go client for the Invoice Ninja API. 6 | // It supports both cloud-hosted and self-hosted Invoice Ninja instances. 7 | -------------------------------------------------------------------------------- /testdata/fixtures/payment.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "id": "test-payment-id", 4 | "client_id": "test-client-id", 5 | "number": "0001", 6 | "amount": 250.00, 7 | "date": "2024-01-15", 8 | "transaction_reference": "TXN-12345", 9 | "private_notes": "Test payment", 10 | "is_deleted": false, 11 | "updated_at": 1705305600 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /testdata/fixtures/payments_list.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "id": "test-payment-1", 5 | "number": "0001", 6 | "amount": 250.00 7 | }, 8 | { 9 | "id": "test-payment-2", 10 | "number": "0002", 11 | "amount": 150.00 12 | } 13 | ], 14 | "meta": { 15 | "pagination": { 16 | "total": 2, 17 | "count": 2, 18 | "per_page": 20, 19 | "current_page": 1, 20 | "total_pages": 1 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 💬 Discussion Forum 4 | url: https://github.com/AshkanYarmoradi/go-invoice-ninja/discussions 5 | about: Ask questions and discuss ideas with the community 6 | - name: 📖 Documentation 7 | url: https://github.com/AshkanYarmoradi/go-invoice-ninja/tree/main/docs 8 | about: Read the SDK documentation 9 | - name: 🔧 Invoice Ninja Support 10 | url: https://invoiceninja.github.io/en/ 11 | about: For issues with Invoice Ninja itself (not this SDK) 12 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | This directory contains detailed documentation for the Go Invoice Ninja SDK. 4 | 5 | ## Contents 6 | 7 | - [Getting Started](getting-started.md) - Quick start guide 8 | - [Authentication](authentication.md) - API token setup and configuration 9 | - [Error Handling](error-handling.md) - Working with errors and retries 10 | - [API Reference](api-reference.md) - Detailed API documentation 11 | 12 | ## Quick Links 13 | 14 | - [Invoice Ninja API Documentation](https://api-docs.invoicing.co/) 15 | - [Invoice Ninja User Guide](https://invoiceninja.github.io/) 16 | - [GitHub Repository](https://github.com/AshkanYarmoradi/go-invoice-ninja) 17 | -------------------------------------------------------------------------------- /testdata/README.md: -------------------------------------------------------------------------------- 1 | # Test Data 2 | 3 | This directory contains test fixtures and mock data for unit tests. 4 | 5 | ## Structure 6 | 7 | ``` 8 | testdata/ 9 | ├── fixtures/ # JSON response fixtures 10 | │ ├── payments/ 11 | │ ├── invoices/ 12 | │ └── clients/ 13 | └── README.md 14 | ``` 15 | 16 | ## Usage 17 | 18 | Test fixtures are used by the test suite to mock API responses: 19 | 20 | ```go 21 | func loadFixture(t *testing.T, name string) []byte { 22 | data, err := os.ReadFile(filepath.Join("testdata", "fixtures", name)) 23 | if err != nil { 24 | t.Fatalf("Failed to load fixture %s: %v", name, err) 25 | } 26 | return data 27 | } 28 | ``` 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories 15 | vendor/ 16 | 17 | # Go workspace file 18 | go.work 19 | go.work.sum 20 | 21 | # IDE directories 22 | .idea/ 23 | .vscode/ 24 | *.swp 25 | *.swo 26 | *~ 27 | 28 | # OS files 29 | .DS_Store 30 | Thumbs.db 31 | 32 | # Build output 33 | dist/ 34 | build/ 35 | 36 | # Coverage reports 37 | coverage.html 38 | coverage.out 39 | coverage.txt 40 | 41 | # Test cache 42 | .testcache/ 43 | 44 | # Environment files 45 | .env 46 | .env.local 47 | .env.*.local 48 | 49 | # Debug files 50 | debug 51 | *.log 52 | 53 | # Temporary files 54 | tmp/ 55 | temp/ 56 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | release: 13 | name: Release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Setup Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version: '1.22' 25 | 26 | - name: Run tests 27 | run: go test -v -race -coverprofile=coverage.txt ./... 28 | 29 | - name: Upload coverage reports to Codecov 30 | uses: codecov/codecov-action@v5 31 | with: 32 | token: ${{ secrets.CODECOV_TOKEN }} 33 | 34 | - name: Create Release 35 | uses: softprops/action-gh-release@v1 36 | with: 37 | generate_release_notes: true 38 | draft: false 39 | prerelease: false 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Ashkan Yarmoradi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/basic/README.md: -------------------------------------------------------------------------------- 1 | # Basic Example 2 | 3 | This example demonstrates basic usage of the Go Invoice Ninja SDK. 4 | 5 | ## Features Demonstrated 6 | 7 | - Creating a client with configuration options 8 | - Listing payments with pagination 9 | - Listing invoices 10 | - Listing clients 11 | - Proper error handling 12 | 13 | ## Prerequisites 14 | 15 | 1. An Invoice Ninja account (cloud or self-hosted) 16 | 2. An API token from Settings > Account Management > Integrations > API tokens 17 | 18 | ## Running the Example 19 | 20 | ```bash 21 | # Set your API token 22 | export INVOICE_NINJA_TOKEN="your-api-token-here" 23 | 24 | # Run the example 25 | go run main.go 26 | ``` 27 | 28 | ## Expected Output 29 | 30 | ``` 31 | === Listing Payments === 32 | Found 5 payments 33 | - Payment 0001: $250.00 (Client: abc123) 34 | - Payment 0002: $150.00 (Client: def456) 35 | ... 36 | 37 | === Listing Invoices === 38 | Found 5 invoices 39 | - Invoice INV-0001: $500.00 (Status: 4) 40 | ... 41 | 42 | === Listing Clients === 43 | Found 5 clients 44 | - Client 0001: Acme Corporation 45 | ... 46 | 47 | ✅ Basic example completed successfully! 48 | ``` 49 | 50 | ## Self-Hosted Instances 51 | 52 | For self-hosted Invoice Ninja instances, uncomment the `WithBaseURL` option: 53 | 54 | ```go 55 | client := invoiceninja.NewClient(token, 56 | invoiceninja.WithBaseURL("https://your-instance.com"), 57 | ) 58 | ``` 59 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Added 11 | - Initial SDK release 12 | 13 | ## [1.0.0] - 2024-01-15 14 | 15 | ### Added 16 | - Core client with configurable options (timeout, base URL, HTTP client) 17 | - Payments service with full CRUD operations and refund support 18 | - Invoices service with CRUD, bulk actions, and PDF download 19 | - Clients service with CRUD, bulk actions, and merge functionality 20 | - Credits service with CRUD and PDF download 21 | - Payment Terms service with CRUD operations 22 | - Webhooks service with signature verification 23 | - File download support for invoices, credits, quotes, and purchase orders 24 | - Client-side rate limiting with automatic retry logic 25 | - Comprehensive error handling with typed errors 26 | - Generic request method for accessing any API endpoint 27 | - Context support for cancellation and timeouts 28 | - Extensive test coverage (90+ tests) 29 | 30 | ### Security 31 | - Webhook signature verification using HMAC-SHA256 32 | 33 | [Unreleased]: https://github.com/AshkanYarmoradi/go-invoice-ninja/compare/v1.0.0...HEAD 34 | [1.0.0]: https://github.com/AshkanYarmoradi/go-invoice-ninja/releases/tag/v1.0.0 35 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | name: Test 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | go-version: ['1.21', '1.22', '1.23'] 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: ${{ matrix.go-version }} 28 | 29 | - name: Download dependencies 30 | run: go mod download 31 | 32 | - name: Run tests 33 | run: go test -v -race -coverprofile=coverage.txt ./... 34 | 35 | - name: Upload coverage 36 | uses: codecov/codecov-action@v5 37 | if: matrix.go-version == '1.22' 38 | with: 39 | token: ${{ secrets.CODECOV_TOKEN }} 40 | 41 | lint: 42 | name: Lint 43 | runs-on: ubuntu-latest 44 | steps: 45 | - name: Checkout code 46 | uses: actions/checkout@v4 47 | 48 | - name: Setup Go 49 | uses: actions/setup-go@v5 50 | with: 51 | go-version: '1.22' 52 | 53 | - name: Run golangci-lint 54 | uses: golangci/golangci-lint-action@v4 55 | with: 56 | version: latest 57 | args: --timeout=5m 58 | 59 | build: 60 | name: Build 61 | runs-on: ubuntu-latest 62 | needs: [test, lint] 63 | steps: 64 | - name: Checkout code 65 | uses: actions/checkout@v4 66 | 67 | - name: Setup Go 68 | uses: actions/setup-go@v5 69 | with: 70 | go-version: '1.22' 71 | 72 | - name: Build 73 | run: go build ./... 74 | -------------------------------------------------------------------------------- /examples/webhooks/README.md: -------------------------------------------------------------------------------- 1 | # Webhooks Example 2 | 3 | This example demonstrates how to handle webhooks from Invoice Ninja. 4 | 5 | ## Features Demonstrated 6 | 7 | - Setting up a webhook HTTP endpoint 8 | - Verifying webhook signatures (HMAC-SHA256) 9 | - Parsing webhook events 10 | - Handling different event types 11 | 12 | ## Prerequisites 13 | 14 | 1. An Invoice Ninja account (cloud or self-hosted) 15 | 2. A publicly accessible URL (for Invoice Ninja to send webhooks) 16 | 3. A webhook secret configured in Invoice Ninja 17 | 18 | ## Setting Up Webhooks in Invoice Ninja 19 | 20 | 1. Go to Settings > Webhooks 21 | 2. Create a new webhook 22 | 3. Set the Target URL to your server's webhook endpoint 23 | 4. Copy the Secret and set it as `INVOICE_NINJA_WEBHOOK_SECRET` 24 | 5. Select the events you want to receive 25 | 26 | ## Running the Example 27 | 28 | ```bash 29 | # Set your webhook secret 30 | export INVOICE_NINJA_WEBHOOK_SECRET="your-webhook-secret-here" 31 | 32 | # Optional: Set a custom port 33 | export PORT=8080 34 | 35 | # Run the server 36 | go run main.go 37 | ``` 38 | 39 | ## Testing Locally 40 | 41 | For local development, you can use a tool like [ngrok](https://ngrok.com/) to expose your local server: 42 | 43 | ```bash 44 | # In one terminal, run the webhook server 45 | go run main.go 46 | 47 | # In another terminal, expose it with ngrok 48 | ngrok http 8080 49 | ``` 50 | 51 | Then use the ngrok URL as your webhook endpoint in Invoice Ninja. 52 | 53 | ## Supported Events 54 | 55 | This example handles the following events: 56 | 57 | - `payment.created` - When a new payment is recorded 58 | - `invoice.paid` - When an invoice is fully paid 59 | - `client.created` - When a new client is created 60 | 61 | ## Security 62 | 63 | Always verify webhook signatures to ensure the request came from Invoice Ninja: 64 | 65 | ```go 66 | if !webhookHandler.VerifySignature(body, signature) { 67 | // Reject the request 68 | } 69 | ``` 70 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # golangci-lint configuration 2 | # https://golangci-lint.run/usage/configuration/ 3 | 4 | run: 5 | timeout: 5m 6 | modules-download-mode: readonly 7 | 8 | linters: 9 | enable: 10 | - bodyclose 11 | - dogsled 12 | - errcheck 13 | - exhaustive 14 | - funlen 15 | - gochecknoinits 16 | - goconst 17 | - gocritic 18 | - gocyclo 19 | - godot 20 | - gofmt 21 | - goimports 22 | - mnd 23 | - goprintffuncname 24 | - gosec 25 | - gosimple 26 | - govet 27 | - ineffassign 28 | - lll 29 | - misspell 30 | - nakedret 31 | - noctx 32 | - nolintlint 33 | - prealloc 34 | - revive 35 | - rowserrcheck 36 | - staticcheck 37 | - stylecheck 38 | - typecheck 39 | - unconvert 40 | - unparam 41 | - unused 42 | - whitespace 43 | 44 | linters-settings: 45 | dupl: 46 | threshold: 150 47 | funlen: 48 | lines: 150 49 | statements: 60 50 | goconst: 51 | min-len: 2 52 | min-occurrences: 3 53 | gocritic: 54 | enabled-tags: 55 | - diagnostic 56 | - performance 57 | - style 58 | disabled-checks: 59 | - paramTypeCombine 60 | - httpNoBody 61 | - rangeValCopy 62 | gocyclo: 63 | min-complexity: 20 64 | goimports: 65 | local-prefixes: github.com/AshkanYarmoradi/go-invoice-ninja 66 | mnd: 67 | # Only check for magic numbers in specific contexts 68 | ignored-numbers: 69 | - "0" 70 | - "1" 71 | - "2" 72 | - "2.0" 73 | - "3" 74 | - "10" 75 | - "30" 76 | - "60" 77 | - "0.3" 78 | - "400" 79 | - "500" 80 | - "1000" 81 | - "1000.0" 82 | ignored-functions: 83 | - "strconv.*" 84 | - "time.*" 85 | - "http.*" 86 | - "big.NewInt" 87 | govet: 88 | enable: 89 | - shadow 90 | lll: 91 | line-length: 140 92 | misspell: 93 | locale: US 94 | nolintlint: 95 | allow-leading-space: false 96 | require-explanation: true 97 | require-specific: true 98 | revive: 99 | rules: 100 | - name: unexported-return 101 | disabled: true 102 | 103 | issues: 104 | exclude-rules: 105 | # Exclude test files from certain linters 106 | - path: _test\.go 107 | linters: 108 | - funlen 109 | - dupl 110 | - mnd 111 | - goconst 112 | - errcheck 113 | - gosec 114 | # Exclude example files from certain linters 115 | - path: example 116 | linters: 117 | - mnd 118 | - goconst 119 | -------------------------------------------------------------------------------- /examples/basic/main.go: -------------------------------------------------------------------------------- 1 | // Package main demonstrates basic usage of the Go Invoice Ninja SDK. 2 | // 3 | // This example shows how to: 4 | // - Create a client with configuration options 5 | // - List payments with pagination 6 | // - Handle errors properly 7 | // 8 | // Run with: go run main.go 9 | package main 10 | 11 | import ( 12 | "context" 13 | "fmt" 14 | "log" 15 | "os" 16 | "time" 17 | 18 | invoiceninja "github.com/AshkanYarmoradi/go-invoice-ninja" 19 | ) 20 | 21 | func main() { 22 | // Get API token from environment variable 23 | token := os.Getenv("INVOICE_NINJA_TOKEN") 24 | if token == "" { 25 | log.Fatal("INVOICE_NINJA_TOKEN environment variable is required") 26 | } 27 | 28 | // Create a new client with options 29 | client := invoiceninja.NewClient(token, 30 | invoiceninja.WithTimeout(30*time.Second), 31 | // Uncomment for self-hosted instances: 32 | // invoiceninja.WithBaseURL("https://your-instance.com"), 33 | ) 34 | 35 | ctx := context.Background() 36 | 37 | // Example 1: List payments 38 | fmt.Println("=== Listing Payments ===") 39 | payments, err := client.Payments.List(ctx, &invoiceninja.PaymentListOptions{ 40 | PerPage: 5, 41 | Page: 1, 42 | }) 43 | if err != nil { 44 | // Handle specific error types 45 | if apiErr, ok := err.(*invoiceninja.APIError); ok { 46 | if apiErr.IsUnauthorized() { 47 | log.Fatal("Invalid API token") 48 | } 49 | if apiErr.IsRateLimited() { 50 | log.Fatal("Rate limited, try again later") 51 | } 52 | log.Fatalf("API error: %v", apiErr) 53 | } 54 | log.Fatalf("Error listing payments: %v", err) 55 | } 56 | 57 | fmt.Printf("Found %d payments\n", len(payments.Data)) 58 | for _, p := range payments.Data { 59 | fmt.Printf(" - Payment %s: $%.2f (Client: %s)\n", p.Number, p.Amount, p.ClientID) 60 | } 61 | 62 | // Example 2: List invoices 63 | fmt.Println("\n=== Listing Invoices ===") 64 | invoices, err := client.Invoices.List(ctx, &invoiceninja.InvoiceListOptions{ 65 | PerPage: 5, 66 | Page: 1, 67 | }) 68 | if err != nil { 69 | log.Fatalf("Error listing invoices: %v", err) 70 | } 71 | 72 | fmt.Printf("Found %d invoices\n", len(invoices.Data)) 73 | for _, inv := range invoices.Data { 74 | fmt.Printf(" - Invoice %s: $%.2f (Status: %s)\n", inv.Number, inv.Amount, inv.StatusID) 75 | } 76 | 77 | // Example 3: List clients 78 | fmt.Println("\n=== Listing Clients ===") 79 | clients, err := client.Clients.List(ctx, &invoiceninja.ClientListOptions{ 80 | PerPage: 5, 81 | Page: 1, 82 | }) 83 | if err != nil { 84 | log.Fatalf("Error listing clients: %v", err) 85 | } 86 | 87 | fmt.Printf("Found %d clients\n", len(clients.Data)) 88 | for _, c := range clients.Data { 89 | fmt.Printf(" - Client %s: %s\n", c.Number, c.Name) 90 | } 91 | 92 | fmt.Println("\n✅ Basic example completed successfully!") 93 | } 94 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Documentation Issue 2 | description: Report missing, incorrect, or unclear documentation 3 | title: "[Docs]: " 4 | labels: ["documentation", "needs-triage"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for helping improve our documentation! Please fill out this form as completely as possible. 10 | 11 | - type: dropdown 12 | id: doc-type 13 | attributes: 14 | label: Documentation Type 15 | description: What type of documentation issue is this? 16 | options: 17 | - Missing documentation 18 | - Incorrect/outdated documentation 19 | - Unclear documentation 20 | - Typo or formatting issue 21 | - Code example issue 22 | - API reference issue 23 | validations: 24 | required: true 25 | 26 | - type: input 27 | id: location 28 | attributes: 29 | label: Documentation Location 30 | description: Where is the documentation issue located? 31 | placeholder: e.g., README.md, docs/getting-started.md, code comments in client.go 32 | validations: 33 | required: true 34 | 35 | - type: textarea 36 | id: current 37 | attributes: 38 | label: Current Documentation 39 | description: What does the current documentation say (if anything)? 40 | placeholder: Quote or describe the current documentation 41 | validations: 42 | required: false 43 | 44 | - type: textarea 45 | id: issue 46 | attributes: 47 | label: What's the issue? 48 | description: Describe what's missing, incorrect, or unclear 49 | placeholder: The documentation doesn't explain... 50 | validations: 51 | required: true 52 | 53 | - type: textarea 54 | id: suggestion 55 | attributes: 56 | label: Suggested Improvement 57 | description: How would you improve this documentation? 58 | placeholder: It should explain that... 59 | validations: 60 | required: false 61 | 62 | - type: textarea 63 | id: example 64 | attributes: 65 | label: Code Example 66 | description: If applicable, provide a code example that demonstrates the issue or suggested improvement 67 | render: go 68 | validations: 69 | required: false 70 | 71 | - type: dropdown 72 | id: contribution 73 | attributes: 74 | label: Are you willing to contribute? 75 | description: Would you be interested in submitting a pull request to fix this documentation issue? 76 | options: 77 | - "Yes, I'd like to contribute" 78 | - "Maybe, with guidance" 79 | - "No" 80 | validations: 81 | required: false 82 | 83 | - type: textarea 84 | id: additional 85 | attributes: 86 | label: Additional context 87 | description: Add any other context about the documentation issue here. 88 | validations: 89 | required: false 90 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This guide will help you get started with the Go Invoice Ninja SDK. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | go get github.com/AshkanYarmoradi/go-invoice-ninja 9 | ``` 10 | 11 | ## Prerequisites 12 | 13 | Before using this SDK, you need: 14 | 15 | 1. **An Invoice Ninja Account** 16 | - Cloud: Sign up at [invoiceninja.com](https://invoiceninja.com) 17 | - Self-hosted: [Installation Guide](https://invoiceninja.github.io/docs/self-host-installation/) 18 | 19 | 2. **An API Token** 20 | - Go to Settings > Account Management > Integrations > API tokens 21 | - Create a new token and save it securely 22 | 23 | ## Basic Usage 24 | 25 | ```go 26 | package main 27 | 28 | import ( 29 | "context" 30 | "fmt" 31 | "log" 32 | 33 | invoiceninja "github.com/AshkanYarmoradi/go-invoice-ninja" 34 | ) 35 | 36 | func main() { 37 | // Create a client 38 | client := invoiceninja.NewClient("your-api-token") 39 | ctx := context.Background() 40 | 41 | // List payments 42 | payments, err := client.Payments.List(ctx, nil) 43 | if err != nil { 44 | log.Fatal(err) 45 | } 46 | 47 | for _, p := range payments.Data { 48 | fmt.Printf("Payment: %s - $%.2f\n", p.Number, p.Amount) 49 | } 50 | } 51 | ``` 52 | 53 | ## Configuration Options 54 | 55 | ### Custom Base URL (Self-Hosted) 56 | 57 | ```go 58 | client := invoiceninja.NewClient("token", 59 | invoiceninja.WithBaseURL("https://your-instance.com")) 60 | ``` 61 | 62 | ### Custom Timeout 63 | 64 | ```go 65 | client := invoiceninja.NewClient("token", 66 | invoiceninja.WithTimeout(60 * time.Second)) 67 | ``` 68 | 69 | ### Custom HTTP Client 70 | 71 | ```go 72 | httpClient := &http.Client{ 73 | Transport: &http.Transport{ 74 | MaxIdleConns: 100, 75 | IdleConnTimeout: 90 * time.Second, 76 | }, 77 | } 78 | 79 | client := invoiceninja.NewClient("token", 80 | invoiceninja.WithHTTPClient(httpClient)) 81 | ``` 82 | 83 | ### Rate Limiting 84 | 85 | ```go 86 | client := invoiceninja.NewClient("token", 87 | invoiceninja.WithRateLimiter(invoiceninja.NewRateLimiter(10))) // 10 req/sec 88 | ``` 89 | 90 | ## Available Services 91 | 92 | The SDK provides the following services: 93 | 94 | | Service | Description | 95 | |---------|-------------| 96 | | `client.Payments` | Payment operations | 97 | | `client.Invoices` | Invoice management | 98 | | `client.Clients` | Client management | 99 | | `client.Credits` | Credit operations | 100 | | `client.PaymentTerms` | Payment terms | 101 | | `client.Webhooks` | Webhook management | 102 | | `client.Downloads` | File downloads | 103 | 104 | ## Next Steps 105 | 106 | - [Authentication Guide](authentication.md) 107 | - [Error Handling](error-handling.md) 108 | - [API Reference](api-reference.md) 109 | - [Examples](/examples/) 110 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Go Invoice Ninja SDK 2 | 3 | .PHONY: all build test test-race test-integration coverage lint fmt vet clean help 4 | 5 | # Go parameters 6 | GOCMD=go 7 | GOBUILD=$(GOCMD) build 8 | GOTEST=$(GOCMD) test 9 | GOFMT=$(GOCMD) fmt 10 | GOVET=$(GOCMD) vet 11 | GOMOD=$(GOCMD) mod 12 | GOLINT=golangci-lint 13 | 14 | # Build information 15 | VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") 16 | BUILD_TIME = $(shell date -u '+%Y-%m-%d_%H:%M:%S') 17 | 18 | # Default target 19 | all: lint test build 20 | 21 | ## build: Build the package 22 | build: 23 | @echo "Building..." 24 | $(GOBUILD) ./... 25 | 26 | ## test: Run unit tests 27 | test: 28 | @echo "Running tests..." 29 | $(GOTEST) -v ./... 30 | 31 | ## test-race: Run tests with race detector 32 | test-race: 33 | @echo "Running tests with race detector..." 34 | $(GOTEST) -v -race ./... 35 | 36 | ## test-integration: Run integration tests (requires INVOICE_NINJA_TOKEN) 37 | test-integration: 38 | @echo "Running integration tests..." 39 | $(GOTEST) -v -tags=integration ./... 40 | 41 | ## coverage: Run tests with coverage 42 | coverage: 43 | @echo "Running tests with coverage..." 44 | $(GOTEST) -v -coverprofile=coverage.out -covermode=atomic ./... 45 | $(GOCMD) tool cover -html=coverage.out -o coverage.html 46 | @echo "Coverage report generated: coverage.html" 47 | 48 | ## coverage-text: Show coverage in terminal 49 | coverage-text: 50 | @echo "Running tests with coverage..." 51 | $(GOTEST) -v -coverprofile=coverage.out -covermode=atomic ./... 52 | $(GOCMD) tool cover -func=coverage.out 53 | 54 | ## lint: Run linter 55 | lint: 56 | @echo "Running linter..." 57 | $(GOLINT) run ./... 58 | 59 | ## fmt: Format code 60 | fmt: 61 | @echo "Formatting code..." 62 | $(GOFMT) ./... 63 | 64 | ## vet: Run go vet 65 | vet: 66 | @echo "Running go vet..." 67 | $(GOVET) ./... 68 | 69 | ## tidy: Tidy and verify go modules 70 | tidy: 71 | @echo "Tidying modules..." 72 | $(GOMOD) tidy 73 | $(GOMOD) verify 74 | 75 | ## clean: Clean build artifacts 76 | clean: 77 | @echo "Cleaning..." 78 | rm -f coverage.out coverage.html 79 | $(GOCMD) clean -cache -testcache 80 | 81 | ## check: Run all checks (fmt, vet, lint, test) 82 | check: fmt vet lint test 83 | 84 | ## pre-commit: Run before committing 85 | pre-commit: tidy fmt lint test 86 | 87 | ## install-tools: Install development tools 88 | install-tools: 89 | @echo "Installing development tools..." 90 | go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest 91 | 92 | ## docs: Generate documentation 93 | docs: 94 | @echo "Generating documentation..." 95 | $(GOCMD) doc -all . > docs/api.txt 96 | 97 | ## help: Show this help message 98 | help: 99 | @echo "Usage: make [target]" 100 | @echo "" 101 | @echo "Targets:" 102 | @sed -n 's/^## //p' $(MAKEFILE_LIST) | column -t -s ':' | sed 's/^/ /' 103 | 104 | # Default help 105 | .DEFAULT_GOAL := help 106 | -------------------------------------------------------------------------------- /examples/invoices/main.go: -------------------------------------------------------------------------------- 1 | // Package main demonstrates creating and managing invoices. 2 | // 3 | // This example shows how to: 4 | // - Create an invoice with line items 5 | // - Apply discounts and taxes 6 | // - Update invoice status 7 | // - Download invoice PDF 8 | // 9 | // Run with: go run main.go 10 | package main 11 | 12 | import ( 13 | "context" 14 | "fmt" 15 | "log" 16 | "os" 17 | "time" 18 | 19 | invoiceninja "github.com/AshkanYarmoradi/go-invoice-ninja" 20 | ) 21 | 22 | func main() { 23 | token := os.Getenv("INVOICE_NINJA_TOKEN") 24 | if token == "" { 25 | log.Fatal("INVOICE_NINJA_TOKEN environment variable is required") 26 | } 27 | 28 | // For this example, you also need a client ID 29 | clientID := os.Getenv("INVOICE_NINJA_CLIENT_ID") 30 | if clientID == "" { 31 | log.Fatal("INVOICE_NINJA_CLIENT_ID environment variable is required") 32 | } 33 | 34 | client := invoiceninja.NewClient(token) 35 | ctx := context.Background() 36 | 37 | // Create an invoice with line items 38 | fmt.Println("=== Creating Invoice ===") 39 | invoice, err := client.Invoices.Create(ctx, &invoiceninja.Invoice{ 40 | ClientID: clientID, 41 | Date: time.Now().Format("2006-01-02"), 42 | DueDate: time.Now().AddDate(0, 0, 30).Format("2006-01-02"), 43 | LineItems: []invoiceninja.LineItem{ 44 | { 45 | ProductKey: "Consulting", 46 | Notes: "Professional consulting services", 47 | Quantity: 10, 48 | Cost: 150.00, 49 | }, 50 | { 51 | ProductKey: "Development", 52 | Notes: "Custom software development", 53 | Quantity: 20, 54 | Cost: 125.00, 55 | }, 56 | { 57 | ProductKey: "Support", 58 | Notes: "Technical support hours", 59 | Quantity: 5, 60 | Cost: 75.00, 61 | }, 62 | }, 63 | PublicNotes: "Thank you for your business!", 64 | Terms: "Payment due within 30 days", 65 | Footer: "Please make checks payable to Acme Corp", 66 | }) 67 | if err != nil { 68 | log.Fatalf("Error creating invoice: %v", err) 69 | } 70 | 71 | fmt.Printf("Created invoice: %s\n", invoice.Number) 72 | fmt.Printf(" Amount: $%.2f\n", invoice.Amount) 73 | fmt.Printf(" Balance: $%.2f\n", invoice.Balance) 74 | 75 | // Get the invoice details 76 | fmt.Println("\n=== Getting Invoice Details ===") 77 | inv, err := client.Invoices.Get(ctx, invoice.ID) 78 | if err != nil { 79 | log.Fatalf("Error getting invoice: %v", err) 80 | } 81 | 82 | fmt.Printf("Invoice %s details:\n", inv.Number) 83 | fmt.Printf(" Client ID: %s\n", inv.ClientID) 84 | fmt.Printf(" Date: %s\n", inv.Date) 85 | fmt.Printf(" Due Date: %s\n", inv.DueDate) 86 | fmt.Printf(" Status: %s\n", inv.StatusID) 87 | fmt.Printf(" Line Items: %d\n", len(inv.LineItems)) 88 | 89 | // Update the invoice 90 | fmt.Println("\n=== Updating Invoice ===") 91 | inv.PublicNotes = "Thank you for choosing our services!" 92 | updated, err := client.Invoices.Update(ctx, inv.ID, inv) 93 | if err != nil { 94 | log.Fatalf("Error updating invoice: %v", err) 95 | } 96 | fmt.Printf("Updated invoice notes: %s\n", updated.PublicNotes) 97 | 98 | fmt.Println("\n✅ Invoice example completed successfully!") 99 | } 100 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a bug or unexpected behavior 3 | title: "[Bug]: " 4 | labels: ["bug", "needs-triage"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to report a bug! Please fill out this form as completely as possible. 10 | 11 | - type: textarea 12 | id: description 13 | attributes: 14 | label: Describe the bug 15 | description: A clear and concise description of what the bug is. 16 | placeholder: Tell us what you see! 17 | validations: 18 | required: true 19 | 20 | - type: textarea 21 | id: reproduction 22 | attributes: 23 | label: Steps to reproduce 24 | description: Steps to reproduce the behavior 25 | placeholder: | 26 | 1. Initialize client with '...' 27 | 2. Call method '...' 28 | 3. See error 29 | validations: 30 | required: true 31 | 32 | - type: textarea 33 | id: expected 34 | attributes: 35 | label: Expected behavior 36 | description: A clear and concise description of what you expected to happen. 37 | placeholder: What should happen instead? 38 | validations: 39 | required: true 40 | 41 | - type: textarea 42 | id: actual 43 | attributes: 44 | label: Actual behavior 45 | description: What actually happened? 46 | placeholder: What actually happens? 47 | validations: 48 | required: true 49 | 50 | - type: textarea 51 | id: code 52 | attributes: 53 | label: Code sample 54 | description: Please provide a minimal code sample that reproduces the issue 55 | render: go 56 | placeholder: | 57 | package main 58 | 59 | import "github.com/AshkanYarmoradi/go-invoice-ninja" 60 | 61 | func main() { 62 | // Your code here 63 | } 64 | validations: 65 | required: false 66 | 67 | - type: input 68 | id: version 69 | attributes: 70 | label: SDK Version 71 | description: What version of the SDK are you using? 72 | placeholder: v1.0.0 73 | validations: 74 | required: true 75 | 76 | - type: input 77 | id: go-version 78 | attributes: 79 | label: Go Version 80 | description: What version of Go are you using? 81 | placeholder: go1.21.0 82 | validations: 83 | required: true 84 | 85 | - type: dropdown 86 | id: invoice-ninja-type 87 | attributes: 88 | label: Invoice Ninja Instance 89 | description: Are you using Invoice Ninja cloud or self-hosted? 90 | options: 91 | - Cloud (invoicing.co) 92 | - Self-hosted 93 | - Not sure 94 | validations: 95 | required: true 96 | 97 | - type: input 98 | id: os 99 | attributes: 100 | label: Operating System 101 | description: What operating system are you using? 102 | placeholder: e.g., macOS 14.0, Ubuntu 22.04, Windows 11 103 | validations: 104 | required: false 105 | 106 | - type: textarea 107 | id: additional 108 | attributes: 109 | label: Additional context 110 | description: Add any other context about the problem here. 111 | placeholder: Logs, screenshots, or other relevant information 112 | validations: 113 | required: false 114 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package invoiceninja 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | ) 9 | 10 | // APIError represents an error returned by the Invoice Ninja API. 11 | type APIError struct { 12 | // StatusCode is the HTTP status code. 13 | StatusCode int `json:"-"` 14 | 15 | // Message is the error message. 16 | Message string `json:"message,omitempty"` 17 | 18 | // Errors contains field-specific validation errors. 19 | Errors map[string][]string `json:"errors,omitempty"` 20 | } 21 | 22 | // Error implements the error interface. 23 | func (e *APIError) Error() string { 24 | if e.Message != "" { 25 | return fmt.Sprintf("Invoice Ninja API error (status %d): %s", e.StatusCode, e.Message) 26 | } 27 | return fmt.Sprintf("Invoice Ninja API error (status %d)", e.StatusCode) 28 | } 29 | 30 | // IsNotFound returns true if the error is a 404 Not Found error. 31 | func (e *APIError) IsNotFound() bool { 32 | return e.StatusCode == http.StatusNotFound 33 | } 34 | 35 | // IsUnauthorized returns true if the error is a 401 Unauthorized error. 36 | func (e *APIError) IsUnauthorized() bool { 37 | return e.StatusCode == http.StatusUnauthorized 38 | } 39 | 40 | // IsForbidden returns true if the error is a 403 Forbidden error. 41 | func (e *APIError) IsForbidden() bool { 42 | return e.StatusCode == http.StatusForbidden 43 | } 44 | 45 | // IsValidationError returns true if the error is a 422 Unprocessable Entity error. 46 | func (e *APIError) IsValidationError() bool { 47 | return e.StatusCode == http.StatusUnprocessableEntity 48 | } 49 | 50 | // IsRateLimited returns true if the error is a 429 Too Many Requests error. 51 | func (e *APIError) IsRateLimited() bool { 52 | return e.StatusCode == http.StatusTooManyRequests 53 | } 54 | 55 | // IsServerError returns true if the error is a 5xx server error. 56 | func (e *APIError) IsServerError() bool { 57 | return e.StatusCode >= 500 58 | } 59 | 60 | // parseAPIError parses an API error response. 61 | func parseAPIError(statusCode int, body []byte) *APIError { 62 | apiErr := &APIError{ 63 | StatusCode: statusCode, 64 | } 65 | 66 | // Try to parse the error response 67 | if len(body) > 0 { 68 | var errResp struct { 69 | Message string `json:"message"` 70 | Errors map[string][]string `json:"errors"` 71 | } 72 | if err := json.Unmarshal(body, &errResp); err == nil { 73 | apiErr.Message = errResp.Message 74 | apiErr.Errors = errResp.Errors 75 | } 76 | } 77 | 78 | // Set default messages for common status codes 79 | if apiErr.Message == "" { 80 | switch statusCode { 81 | case http.StatusBadRequest: 82 | apiErr.Message = "bad request" 83 | case http.StatusUnauthorized: 84 | apiErr.Message = "unauthorized - check your API token" 85 | case http.StatusForbidden: 86 | apiErr.Message = "forbidden - you don't have permission to access this resource" 87 | case http.StatusNotFound: 88 | apiErr.Message = "resource not found" 89 | case http.StatusUnprocessableEntity: 90 | apiErr.Message = "validation error" 91 | case http.StatusTooManyRequests: 92 | apiErr.Message = "rate limit exceeded" 93 | default: 94 | if statusCode >= 500 { 95 | apiErr.Message = "server error" 96 | } 97 | } 98 | } 99 | 100 | return apiErr 101 | } 102 | 103 | // IsAPIError checks if an error is an APIError and returns it. 104 | func IsAPIError(err error) (*APIError, bool) { 105 | var apiErr *APIError 106 | if errors.As(err, &apiErr) { 107 | return apiErr, true 108 | } 109 | return nil, false 110 | } 111 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea or enhancement for this project 3 | title: "[Feature]: " 4 | labels: ["enhancement", "needs-triage"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for suggesting a feature! Please fill out this form as completely as possible. 10 | 11 | - type: textarea 12 | id: problem 13 | attributes: 14 | label: Is your feature request related to a problem? 15 | description: A clear and concise description of what the problem is. 16 | placeholder: I'm always frustrated when... 17 | validations: 18 | required: false 19 | 20 | - type: textarea 21 | id: solution 22 | attributes: 23 | label: Describe the solution you'd like 24 | description: A clear and concise description of what you want to happen. 25 | placeholder: I would like to be able to... 26 | validations: 27 | required: true 28 | 29 | - type: textarea 30 | id: alternatives 31 | attributes: 32 | label: Describe alternatives you've considered 33 | description: A clear and concise description of any alternative solutions or features you've considered. 34 | placeholder: I've also considered... 35 | validations: 36 | required: false 37 | 38 | - type: dropdown 39 | id: api-coverage 40 | attributes: 41 | label: Is this related to Invoice Ninja API coverage? 42 | description: Does this feature request involve adding support for an existing Invoice Ninja API endpoint? 43 | options: 44 | - "Yes - New API endpoint support" 45 | - "No - SDK improvement" 46 | - "Not sure" 47 | validations: 48 | required: true 49 | 50 | - type: textarea 51 | id: api-docs 52 | attributes: 53 | label: API Documentation 54 | description: If this is related to API coverage, please provide a link to the Invoice Ninja API documentation 55 | placeholder: https://api-docs.invoicing.co/#/... 56 | validations: 57 | required: false 58 | 59 | - type: textarea 60 | id: use-case 61 | attributes: 62 | label: Use case 63 | description: Describe your use case and how this feature would help you 64 | placeholder: I need this feature because... 65 | validations: 66 | required: false 67 | 68 | - type: textarea 69 | id: example 70 | attributes: 71 | label: Code example 72 | description: If applicable, provide a code example of how you would like to use this feature 73 | render: go 74 | placeholder: | 75 | package main 76 | 77 | import "github.com/AshkanYarmoradi/go-invoice-ninja" 78 | 79 | func main() { 80 | // Example of how you'd like to use the feature 81 | } 82 | validations: 83 | required: false 84 | 85 | - type: dropdown 86 | id: contribution 87 | attributes: 88 | label: Are you willing to contribute? 89 | description: Would you be interested in contributing this feature via a pull request? 90 | options: 91 | - "Yes, I'd like to contribute" 92 | - "Maybe, with guidance" 93 | - "No, but I'd be happy to test" 94 | - "No" 95 | validations: 96 | required: false 97 | 98 | - type: textarea 99 | id: additional 100 | attributes: 101 | label: Additional context 102 | description: Add any other context or screenshots about the feature request here. 103 | validations: 104 | required: false 105 | -------------------------------------------------------------------------------- /docs/authentication.md: -------------------------------------------------------------------------------- 1 | # Authentication 2 | 3 | This guide covers authentication with the Invoice Ninja API. 4 | 5 | ## API Tokens 6 | 7 | The Invoice Ninja API uses token-based authentication. All API requests must include your API token in the `X-API-TOKEN` header. 8 | 9 | ### Obtaining an API Token 10 | 11 | 1. Log in to your Invoice Ninja account 12 | 2. Navigate to Settings > Account Management > Integrations 13 | 3. Click on "API Tokens" 14 | 4. Create a new token 15 | 5. Copy the token immediately (it won't be shown again) 16 | 17 | ### Using Your Token 18 | 19 | ```go 20 | client := invoiceninja.NewClient("your-api-token") 21 | ``` 22 | 23 | The SDK automatically includes the token in all requests. 24 | 25 | ## Security Best Practices 26 | 27 | ### Environment Variables 28 | 29 | Never hardcode your API token. Use environment variables: 30 | 31 | ```go 32 | import "os" 33 | 34 | token := os.Getenv("INVOICE_NINJA_TOKEN") 35 | if token == "" { 36 | log.Fatal("INVOICE_NINJA_TOKEN is required") 37 | } 38 | 39 | client := invoiceninja.NewClient(token) 40 | ``` 41 | 42 | ### Token Permissions 43 | 44 | When creating API tokens in Invoice Ninja, consider: 45 | 46 | - **Create separate tokens** for different applications 47 | - **Use descriptive names** to identify token usage 48 | - **Rotate tokens periodically** 49 | - **Revoke unused tokens** 50 | 51 | ### Secure Storage 52 | 53 | - Store tokens in secure secret management systems 54 | - Never commit tokens to version control 55 | - Use `.env` files only for local development 56 | - Encrypt tokens at rest in production 57 | 58 | ## Self-Hosted Authentication 59 | 60 | For self-hosted instances, you may also need to provide an API secret: 61 | 62 | ```go 63 | client := invoiceninja.NewClient("your-api-token", 64 | invoiceninja.WithBaseURL("https://your-instance.com"), 65 | invoiceninja.WithAPISecret("your-api-secret"), // if required 66 | ) 67 | ``` 68 | 69 | ## Handling Authentication Errors 70 | 71 | ```go 72 | payments, err := client.Payments.List(ctx, nil) 73 | if err != nil { 74 | if apiErr, ok := err.(*invoiceninja.APIError); ok { 75 | if apiErr.IsUnauthorized() { 76 | log.Fatal("Invalid API token - please check your credentials") 77 | } 78 | if apiErr.IsForbidden() { 79 | log.Fatal("Access denied - check token permissions") 80 | } 81 | } 82 | log.Fatal(err) 83 | } 84 | ``` 85 | 86 | ## Token Rotation 87 | 88 | To rotate your API token: 89 | 90 | 1. Generate a new token in Invoice Ninja 91 | 2. Update your application configuration 92 | 3. Deploy the new configuration 93 | 4. Revoke the old token 94 | 95 | ```go 96 | // Example: Token rotation with graceful fallback 97 | func createClient() *invoiceninja.Client { 98 | token := os.Getenv("INVOICE_NINJA_TOKEN") 99 | 100 | // Optional: Support for rotating tokens 101 | fallbackToken := os.Getenv("INVOICE_NINJA_TOKEN_FALLBACK") 102 | 103 | client := invoiceninja.NewClient(token) 104 | 105 | // Test the connection 106 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 107 | defer cancel() 108 | 109 | _, err := client.Payments.List(ctx, &invoiceninja.PaymentListOptions{PerPage: 1}) 110 | if err != nil { 111 | if apiErr, ok := err.(*invoiceninja.APIError); ok && apiErr.IsUnauthorized() { 112 | if fallbackToken != "" { 113 | log.Println("Primary token failed, trying fallback") 114 | return invoiceninja.NewClient(fallbackToken) 115 | } 116 | } 117 | log.Fatalf("Authentication failed: %v", err) 118 | } 119 | 120 | return client 121 | } 122 | ``` 123 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | $$$ 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | Go 72 | Invoice 73 | Ninja 74 | 75 | 76 | 77 | 78 | 79 | SDK 80 | 81 | -------------------------------------------------------------------------------- /examples/webhooks/main.go: -------------------------------------------------------------------------------- 1 | // Package main demonstrates webhook handling. 2 | // 3 | // This example shows how to: 4 | // - Set up a webhook endpoint 5 | // - Register event handlers 6 | // - Handle different webhook events 7 | // 8 | // Run with: go run main.go 9 | package main 10 | 11 | import ( 12 | "encoding/json" 13 | "fmt" 14 | "log" 15 | "net/http" 16 | "os" 17 | 18 | invoiceninja "github.com/AshkanYarmoradi/go-invoice-ninja" 19 | ) 20 | 21 | func main() { 22 | webhookSecret := os.Getenv("INVOICE_NINJA_WEBHOOK_SECRET") 23 | if webhookSecret == "" { 24 | log.Println("Warning: INVOICE_NINJA_WEBHOOK_SECRET not set, signature verification disabled") 25 | webhookSecret = "" // Empty string disables signature verification 26 | } 27 | 28 | // Create a webhook handler 29 | webhookHandler := invoiceninja.NewWebhookHandler(webhookSecret) 30 | 31 | // Register handlers for different event types using convenience methods 32 | webhookHandler.OnPaymentCreated(handlePaymentCreated) 33 | webhookHandler.OnInvoiceCreated(handleInvoiceCreated) 34 | webhookHandler.OnClientCreated(handleClientCreated) 35 | 36 | // You can also use the generic On method for any event type 37 | webhookHandler.On("invoice.paid", func(event *invoiceninja.WebhookEvent) error { 38 | log.Printf("Invoice paid event received") 39 | prettyJSON, _ := json.MarshalIndent(json.RawMessage(event.Data), "", " ") 40 | log.Printf("Event data:\n%s", prettyJSON) 41 | return nil 42 | }) 43 | 44 | // Use the built-in HTTP handler 45 | http.HandleFunc("/webhook", webhookHandler.HandleRequest) 46 | 47 | port := os.Getenv("PORT") 48 | if port == "" { 49 | port = "8080" 50 | } 51 | 52 | fmt.Printf("Starting webhook server on port %s...\n", port) 53 | fmt.Println("Send webhooks to: http://localhost:" + port + "/webhook") 54 | log.Fatal(http.ListenAndServe(":"+port, nil)) 55 | } 56 | 57 | func handlePaymentCreated(event *invoiceninja.WebhookEvent) error { 58 | log.Printf("Payment created event received") 59 | 60 | // Parse the payment data using the helper method 61 | payment, err := event.ParsePayment() 62 | if err != nil { 63 | log.Printf("Could not parse payment data: %v", err) 64 | // Still log the raw data for debugging 65 | prettyJSON, _ := json.MarshalIndent(json.RawMessage(event.Data), "", " ") 66 | log.Printf("Raw event data:\n%s", prettyJSON) 67 | return nil // Don't return error to acknowledge receipt 68 | } 69 | 70 | log.Printf("Payment ID: %s", payment.ID) 71 | log.Printf("Payment Amount: $%.2f", payment.Amount) 72 | log.Printf("Payment Number: %s", payment.Number) 73 | 74 | // Process the payment... 75 | // e.g., update your database, send notifications, etc. 76 | return nil 77 | } 78 | 79 | func handleInvoiceCreated(event *invoiceninja.WebhookEvent) error { 80 | log.Printf("Invoice created event received") 81 | 82 | // Parse the invoice data using the helper method 83 | invoice, err := event.ParseInvoice() 84 | if err != nil { 85 | log.Printf("Could not parse invoice data: %v", err) 86 | prettyJSON, _ := json.MarshalIndent(json.RawMessage(event.Data), "", " ") 87 | log.Printf("Raw event data:\n%s", prettyJSON) 88 | return nil 89 | } 90 | 91 | log.Printf("Invoice ID: %s", invoice.ID) 92 | log.Printf("Invoice Number: %s", invoice.Number) 93 | log.Printf("Invoice Amount: $%.2f", invoice.Amount) 94 | 95 | // Process the invoice... 96 | return nil 97 | } 98 | 99 | func handleClientCreated(event *invoiceninja.WebhookEvent) error { 100 | log.Printf("Client created event received") 101 | 102 | // Parse the client data using the helper method 103 | client, err := event.ParseClient() 104 | if err != nil { 105 | log.Printf("Could not parse client data: %v", err) 106 | prettyJSON, _ := json.MarshalIndent(json.RawMessage(event.Data), "", " ") 107 | log.Printf("Raw event data:\n%s", prettyJSON) 108 | return nil 109 | } 110 | 111 | log.Printf("Client ID: %s", client.ID) 112 | log.Printf("Client Name: %s", client.Name) 113 | 114 | // Process the new client... 115 | return nil 116 | } 117 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package invoiceninja 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | ) 10 | 11 | func TestNewClient(t *testing.T) { 12 | client := NewClient("test-token") 13 | 14 | if client.apiToken != "test-token" { 15 | t.Errorf("expected apiToken to be 'test-token', got '%s'", client.apiToken) 16 | } 17 | 18 | if client.baseURL != DefaultBaseURL { 19 | t.Errorf("expected baseURL to be '%s', got '%s'", DefaultBaseURL, client.baseURL) 20 | } 21 | } 22 | 23 | func TestNewClientWithOptions(t *testing.T) { 24 | customHTTP := &http.Client{} 25 | customURL := "https://custom.example.com" 26 | 27 | client := NewClient("test-token", 28 | WithHTTPClient(customHTTP), 29 | WithBaseURL(customURL), 30 | ) 31 | 32 | if client.httpClient != customHTTP { 33 | t.Error("expected custom HTTP client to be set") 34 | } 35 | 36 | if client.baseURL != customURL { 37 | t.Errorf("expected baseURL to be '%s', got '%s'", customURL, client.baseURL) 38 | } 39 | } 40 | 41 | func TestSetBaseURL(t *testing.T) { 42 | client := NewClient("test-token") 43 | 44 | client.SetBaseURL("https://custom.example.com/") 45 | 46 | // Should trim trailing slash 47 | if client.baseURL != "https://custom.example.com" { 48 | t.Errorf("expected baseURL to be 'https://custom.example.com', got '%s'", client.baseURL) 49 | } 50 | } 51 | 52 | func TestClientRequest(t *testing.T) { 53 | // Create a test server 54 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 55 | // Verify headers 56 | if r.Header.Get("X-API-TOKEN") != "test-token" { 57 | t.Errorf("expected X-API-TOKEN header to be 'test-token'") 58 | } 59 | if r.Header.Get("X-Requested-With") != "XMLHttpRequest" { 60 | t.Errorf("expected X-Requested-With header to be 'XMLHttpRequest'") 61 | } 62 | if r.Header.Get("Content-Type") != "application/json" { 63 | t.Errorf("expected Content-Type header to be 'application/json'") 64 | } 65 | 66 | // Return a test response 67 | w.Header().Set("Content-Type", "application/json") 68 | json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 69 | })) 70 | defer server.Close() 71 | 72 | client := NewClient("test-token", WithBaseURL(server.URL)) 73 | 74 | var result map[string]string 75 | err := client.Request(context.Background(), "GET", "/test", nil, &result) 76 | 77 | if err != nil { 78 | t.Errorf("unexpected error: %v", err) 79 | } 80 | 81 | if result["status"] != "ok" { 82 | t.Errorf("expected status to be 'ok', got '%s'", result["status"]) 83 | } 84 | } 85 | 86 | func TestClientRequestError(t *testing.T) { 87 | // Create a test server that returns an error 88 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 89 | w.WriteHeader(http.StatusUnauthorized) 90 | json.NewEncoder(w).Encode(map[string]string{"message": "Invalid API token"}) 91 | })) 92 | defer server.Close() 93 | 94 | client := NewClient("invalid-token", WithBaseURL(server.URL)) 95 | 96 | var result map[string]string 97 | err := client.Request(context.Background(), "GET", "/test", nil, &result) 98 | 99 | if err == nil { 100 | t.Error("expected error, got nil") 101 | } 102 | 103 | apiErr, ok := IsAPIError(err) 104 | if !ok { 105 | t.Errorf("expected APIError, got %T", err) 106 | } 107 | 108 | if !apiErr.IsUnauthorized() { 109 | t.Errorf("expected IsUnauthorized to be true") 110 | } 111 | } 112 | 113 | func TestClientRequestWithBody(t *testing.T) { 114 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 115 | // Verify request body 116 | var body map[string]interface{} 117 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 118 | t.Errorf("failed to decode request body: %v", err) 119 | } 120 | 121 | if body["name"] != "Test Client" { 122 | t.Errorf("expected name to be 'Test Client', got '%v'", body["name"]) 123 | } 124 | 125 | w.Header().Set("Content-Type", "application/json") 126 | json.NewEncoder(w).Encode(map[string]interface{}{ 127 | "data": map[string]string{"id": "abc123", "name": "Test Client"}, 128 | }) 129 | })) 130 | defer server.Close() 131 | 132 | client := NewClient("test-token", WithBaseURL(server.URL)) 133 | 134 | reqBody := map[string]string{"name": "Test Client"} 135 | var result map[string]interface{} 136 | err := client.Request(context.Background(), "POST", "/test", reqBody, &result) 137 | 138 | if err != nil { 139 | t.Errorf("unexpected error: %v", err) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /assets/banner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | package invoiceninja 30 | func NewClient(token string) *Client { 31 | return &Client{token: token} 32 | } 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | Go 53 | Invoice 54 | Ninja 55 | 56 | 57 | 58 | 59 | Professional Go SDK for Invoice Ninja API 60 | 61 | 62 | 63 | 64 | 65 | ✓ Tested 66 | 67 | 68 | ⚡ Rate Limit 69 | 70 | 71 | 🔔 Webhooks 72 | 73 | 74 | 🔒 Secure 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | $99 94 | 95 | -------------------------------------------------------------------------------- /payment_terms.go: -------------------------------------------------------------------------------- 1 | package invoiceninja 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "strconv" 8 | ) 9 | 10 | // PaymentTermsService handles payment terms-related API operations. 11 | type PaymentTermsService struct { 12 | client *Client 13 | } 14 | 15 | // PaymentTerm represents a payment term in Invoice Ninja. 16 | type PaymentTerm struct { 17 | ID string `json:"id,omitempty"` 18 | Name string `json:"name,omitempty"` 19 | NumDays int `json:"num_days,omitempty"` 20 | IsDefault bool `json:"is_default,omitempty"` 21 | IsDeleted bool `json:"is_deleted,omitempty"` 22 | CreatedAt int64 `json:"created_at,omitempty"` 23 | UpdatedAt int64 `json:"updated_at,omitempty"` 24 | ArchivedAt int64 `json:"archived_at,omitempty"` 25 | } 26 | 27 | // PaymentTermListOptions specifies the optional parameters for listing payment terms. 28 | type PaymentTermListOptions struct { 29 | PerPage int 30 | Page int 31 | Include string 32 | } 33 | 34 | // toQuery converts options to URL query parameters. 35 | func (o *PaymentTermListOptions) toQuery() url.Values { 36 | if o == nil { 37 | return nil 38 | } 39 | 40 | q := url.Values{} 41 | 42 | if o.PerPage > 0 { 43 | q.Set("per_page", strconv.Itoa(o.PerPage)) 44 | } 45 | if o.Page > 0 { 46 | q.Set("page", strconv.Itoa(o.Page)) 47 | } 48 | if o.Include != "" { 49 | q.Set("include", o.Include) 50 | } 51 | 52 | return q 53 | } 54 | 55 | // List retrieves a list of payment terms. 56 | func (s *PaymentTermsService) List(ctx context.Context, opts *PaymentTermListOptions) (*ListResponse[PaymentTerm], error) { 57 | var resp ListResponse[PaymentTerm] 58 | if err := s.client.doRequest(ctx, "GET", "/api/v1/payment_terms", opts.toQuery(), nil, &resp); err != nil { 59 | return nil, err 60 | } 61 | return &resp, nil 62 | } 63 | 64 | // Get retrieves a single payment term by ID. 65 | func (s *PaymentTermsService) Get(ctx context.Context, id string) (*PaymentTerm, error) { 66 | var resp SingleResponse[PaymentTerm] 67 | if err := s.client.doRequest(ctx, "GET", fmt.Sprintf("/api/v1/payment_terms/%s", id), nil, nil, &resp); err != nil { 68 | return nil, err 69 | } 70 | return &resp.Data, nil 71 | } 72 | 73 | // Create creates a new payment term. 74 | func (s *PaymentTermsService) Create(ctx context.Context, term *PaymentTerm) (*PaymentTerm, error) { 75 | var resp SingleResponse[PaymentTerm] 76 | if err := s.client.doRequest(ctx, "POST", "/api/v1/payment_terms", nil, term, &resp); err != nil { 77 | return nil, err 78 | } 79 | return &resp.Data, nil 80 | } 81 | 82 | // Update updates an existing payment term. 83 | func (s *PaymentTermsService) Update(ctx context.Context, id string, term *PaymentTerm) (*PaymentTerm, error) { 84 | var resp SingleResponse[PaymentTerm] 85 | if err := s.client.doRequest(ctx, "PUT", fmt.Sprintf("/api/v1/payment_terms/%s", id), nil, term, &resp); err != nil { 86 | return nil, err 87 | } 88 | return &resp.Data, nil 89 | } 90 | 91 | // Delete deletes a payment term by ID. 92 | func (s *PaymentTermsService) Delete(ctx context.Context, id string) error { 93 | return s.client.doRequest(ctx, "DELETE", fmt.Sprintf("/api/v1/payment_terms/%s", id), nil, nil, nil) 94 | } 95 | 96 | // Bulk performs a bulk action on multiple payment terms. 97 | func (s *PaymentTermsService) Bulk(ctx context.Context, action string, ids []string) ([]PaymentTerm, error) { 98 | req := BulkAction{ 99 | Action: action, 100 | IDs: ids, 101 | } 102 | 103 | var resp ListResponse[PaymentTerm] 104 | if err := s.client.doRequest(ctx, "POST", "/api/v1/payment_terms/bulk", nil, req, &resp); err != nil { 105 | return nil, err 106 | } 107 | return resp.Data, nil 108 | } 109 | 110 | // Archive archives a payment term. 111 | func (s *PaymentTermsService) Archive(ctx context.Context, id string) (*PaymentTerm, error) { 112 | terms, err := s.Bulk(ctx, "archive", []string{id}) 113 | if err != nil { 114 | return nil, err 115 | } 116 | if len(terms) == 0 { 117 | return nil, fmt.Errorf("no payment term returned from bulk action") 118 | } 119 | return &terms[0], nil 120 | } 121 | 122 | // Restore restores an archived payment term. 123 | func (s *PaymentTermsService) Restore(ctx context.Context, id string) (*PaymentTerm, error) { 124 | terms, err := s.Bulk(ctx, "restore", []string{id}) 125 | if err != nil { 126 | return nil, err 127 | } 128 | if len(terms) == 0 { 129 | return nil, fmt.Errorf("no payment term returned from bulk action") 130 | } 131 | return &terms[0], nil 132 | } 133 | 134 | // GetBlank retrieves a blank payment term object with default values. 135 | func (s *PaymentTermsService) GetBlank(ctx context.Context) (*PaymentTerm, error) { 136 | var resp SingleResponse[PaymentTerm] 137 | if err := s.client.doRequest(ctx, "GET", "/api/v1/payment_terms/create", nil, nil, &resp); err != nil { 138 | return nil, err 139 | } 140 | return &resp.Data, nil 141 | } 142 | -------------------------------------------------------------------------------- /files_test.go: -------------------------------------------------------------------------------- 1 | package invoiceninja 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | func TestDownloadsServiceDownloadInvoicePDF(t *testing.T) { 14 | expectedPDF := []byte("%PDF-1.4 fake pdf content") 15 | 16 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | if r.Method != "GET" { 18 | t.Errorf("expected GET method, got %s", r.Method) 19 | } 20 | if !strings.HasPrefix(r.URL.Path, "/api/v1/invoice/") { 21 | t.Errorf("expected path to start with /api/v1/invoice/, got %s", r.URL.Path) 22 | } 23 | if !strings.HasSuffix(r.URL.Path, "/download") { 24 | t.Errorf("expected path to end with /download, got %s", r.URL.Path) 25 | } 26 | 27 | // Verify headers 28 | if r.Header.Get("X-API-TOKEN") != "test-token" { 29 | t.Errorf("expected X-API-TOKEN header") 30 | } 31 | if r.Header.Get("Accept") != "application/pdf" { 32 | t.Errorf("expected Accept: application/pdf header") 33 | } 34 | 35 | w.Header().Set("Content-Type", "application/pdf") 36 | w.Write(expectedPDF) 37 | })) 38 | defer server.Close() 39 | 40 | client := NewClient("test-token", WithBaseURL(server.URL)) 41 | 42 | pdf, err := client.Downloads.DownloadInvoicePDF(context.Background(), "inv-key-123") 43 | if err != nil { 44 | t.Fatalf("unexpected error: %v", err) 45 | } 46 | 47 | if !bytes.Equal(pdf, expectedPDF) { 48 | t.Errorf("expected PDF content to match") 49 | } 50 | } 51 | 52 | func TestDownloadsServiceDownloadError(t *testing.T) { 53 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 54 | w.WriteHeader(http.StatusNotFound) 55 | w.Write([]byte(`{"message": "Invoice not found"}`)) 56 | })) 57 | defer server.Close() 58 | 59 | client := NewClient("test-token", WithBaseURL(server.URL)) 60 | 61 | _, err := client.Downloads.DownloadInvoicePDF(context.Background(), "invalid-key") 62 | if err == nil { 63 | t.Error("expected error, got nil") 64 | } 65 | 66 | apiErr, ok := IsAPIError(err) 67 | if !ok { 68 | t.Errorf("expected APIError, got %T", err) 69 | } 70 | 71 | if !apiErr.IsNotFound() { 72 | t.Errorf("expected IsNotFound to be true") 73 | } 74 | } 75 | 76 | func TestUploadsServiceUploadFromReader(t *testing.T) { 77 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 78 | if r.Method != "POST" { 79 | t.Errorf("expected POST method, got %s", r.Method) 80 | } 81 | if !strings.HasSuffix(r.URL.Path, "/upload") { 82 | t.Errorf("expected path to end with /upload, got %s", r.URL.Path) 83 | } 84 | 85 | // Check content type is multipart 86 | contentType := r.Header.Get("Content-Type") 87 | if !strings.HasPrefix(contentType, "multipart/form-data") { 88 | t.Errorf("expected multipart/form-data content type, got %s", contentType) 89 | } 90 | 91 | // Parse multipart form 92 | if err := r.ParseMultipartForm(10 << 20); err != nil { 93 | t.Errorf("failed to parse multipart form: %v", err) 94 | } 95 | 96 | // Check _method field 97 | if r.FormValue("_method") != "PUT" { 98 | t.Errorf("expected _method=PUT, got %s", r.FormValue("_method")) 99 | } 100 | 101 | // Check file was uploaded 102 | file, header, err := r.FormFile("documents[]") 103 | if err != nil { 104 | t.Errorf("failed to get uploaded file: %v", err) 105 | } 106 | defer file.Close() 107 | 108 | if header.Filename != "test.pdf" { 109 | t.Errorf("expected filename 'test.pdf', got '%s'", header.Filename) 110 | } 111 | 112 | content, _ := io.ReadAll(file) 113 | if string(content) != "test content" { 114 | t.Errorf("expected file content 'test content', got '%s'", string(content)) 115 | } 116 | 117 | w.WriteHeader(http.StatusOK) 118 | })) 119 | defer server.Close() 120 | 121 | client := NewClient("test-token", WithBaseURL(server.URL)) 122 | 123 | reader := strings.NewReader("test content") 124 | err := client.Uploads.UploadDocumentFromReader(context.Background(), "invoices", "inv123", "test.pdf", reader) 125 | if err != nil { 126 | t.Fatalf("unexpected error: %v", err) 127 | } 128 | } 129 | 130 | func TestUploadsServiceUploadError(t *testing.T) { 131 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 132 | w.WriteHeader(http.StatusUnprocessableEntity) 133 | w.Write([]byte(`{"message": "Invalid file type"}`)) 134 | })) 135 | defer server.Close() 136 | 137 | client := NewClient("test-token", WithBaseURL(server.URL)) 138 | 139 | reader := strings.NewReader("test content") 140 | err := client.Uploads.UploadDocumentFromReader(context.Background(), "invoices", "inv123", "test.exe", reader) 141 | if err == nil { 142 | t.Error("expected error, got nil") 143 | } 144 | 145 | apiErr, ok := IsAPIError(err) 146 | if !ok { 147 | t.Errorf("expected APIError, got %T", err) 148 | } 149 | 150 | if !apiErr.IsValidationError() { 151 | t.Errorf("expected IsValidationError to be true") 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Go Invoice Ninja SDK 2 | 3 | First off, thank you for considering contributing to the Go Invoice Ninja SDK! 🎉 4 | 5 | ## Code of Conduct 6 | 7 | This project adheres to a Code of Conduct. By participating, you are expected to uphold this code. 8 | 9 | ## How Can I Contribute? 10 | 11 | ### Reporting Bugs 12 | 13 | Before creating bug reports, please check the existing issues to avoid duplicates. When you create a bug report, include as many details as possible: 14 | 15 | - **Use a clear and descriptive title** 16 | - **Describe the exact steps to reproduce the problem** 17 | - **Provide specific examples** (code snippets, API responses) 18 | - **Describe the behavior you observed and what you expected** 19 | - **Include your Go version** (`go version`) 20 | - **Include the SDK version** you're using 21 | 22 | ### Suggesting Enhancements 23 | 24 | Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion: 25 | 26 | - **Use a clear and descriptive title** 27 | - **Provide a detailed description of the proposed functionality** 28 | - **Explain why this enhancement would be useful** 29 | - **List any alternative solutions you've considered** 30 | 31 | ### Pull Requests 32 | 33 | 1. **Fork the repository** and create your branch from `main` 34 | 2. **Write tests** for any new functionality 35 | 3. **Ensure all tests pass** (`make test`) 36 | 4. **Run the linter** (`make lint`) 37 | 5. **Update documentation** if needed 38 | 6. **Write a clear commit message** 39 | 40 | ## Development Setup 41 | 42 | ### Prerequisites 43 | 44 | - Go 1.21 or later 45 | - Make (optional, but recommended) 46 | - golangci-lint (for linting) 47 | 48 | ### Getting Started 49 | 50 | ```bash 51 | # Clone your fork 52 | git clone https://github.com/YOUR_USERNAME/go-invoice-ninja.git 53 | cd go-invoice-ninja 54 | 55 | # Add upstream remote 56 | git remote add upstream https://github.com/AshkanYarmoradi/go-invoice-ninja.git 57 | 58 | # Install dependencies 59 | go mod download 60 | 61 | # Run tests 62 | make test 63 | 64 | # Run linter 65 | make lint 66 | ``` 67 | 68 | ### Running Tests 69 | 70 | ```bash 71 | # Run all tests 72 | make test 73 | 74 | # Run tests with coverage 75 | make coverage 76 | 77 | # Run tests with race detector 78 | make test-race 79 | 80 | # Run integration tests (requires API token) 81 | INVOICE_NINJA_TOKEN=your-token make test-integration 82 | ``` 83 | 84 | ### Code Style 85 | 86 | This project uses `golangci-lint` for code quality. Please ensure your code passes all linter checks: 87 | 88 | ```bash 89 | make lint 90 | ``` 91 | 92 | Key style guidelines: 93 | - Follow [Effective Go](https://go.dev/doc/effective_go) 94 | - Keep functions focused and under 60 statements 95 | - Add comments for exported functions, types, and constants 96 | - Use meaningful variable names 97 | - Handle errors explicitly 98 | 99 | ### Commit Messages 100 | 101 | We follow conventional commits. Each commit message should be structured as: 102 | 103 | ``` 104 | (): 105 | 106 | [optional body] 107 | 108 | [optional footer] 109 | ``` 110 | 111 | Types: 112 | - `feat`: New feature 113 | - `fix`: Bug fix 114 | - `docs`: Documentation only 115 | - `style`: Code style changes (formatting, etc.) 116 | - `refactor`: Code changes that neither fix bugs nor add features 117 | - `test`: Adding or updating tests 118 | - `chore`: Maintenance tasks 119 | 120 | Examples: 121 | ``` 122 | feat(payments): add support for bulk refunds 123 | fix(client): handle nil response body 124 | docs: update README with webhook examples 125 | ``` 126 | 127 | ## Project Structure 128 | 129 | ``` 130 | go-invoice-ninja/ 131 | ├── .github/workflows/ # CI/CD workflows 132 | ├── docs/ # Additional documentation 133 | ├── examples/ # Runnable examples 134 | ├── testdata/ # Test fixtures 135 | │ 136 | ├── client.go # Main client implementation 137 | ├── clients.go # Clients service 138 | ├── credits.go # Credits service 139 | ├── errors.go # Error types 140 | ├── files.go # File operations 141 | ├── invoices.go # Invoices service 142 | ├── models.go # Data models 143 | ├── payments.go # Payments service 144 | ├── payment_terms.go # Payment terms service 145 | ├── retry.go # Retry and rate limiting 146 | ├── webhooks.go # Webhook handling 147 | └── *_test.go # Test files 148 | ``` 149 | 150 | ## API Design Guidelines 151 | 152 | When adding new functionality: 153 | 154 | 1. **Follow existing patterns** - Look at how similar features are implemented 155 | 2. **Support pagination** - Use `ListOptions` for list operations 156 | 3. **Return typed errors** - Use `APIError` for API errors 157 | 4. **Support context** - All operations should accept `context.Context` 158 | 5. **Add tests** - Unit tests and integration tests where applicable 159 | 160 | ### Adding a New Service 161 | 162 | 1. Create `service_name.go` with the service struct and methods 163 | 2. Create `service_name_test.go` with unit tests 164 | 3. Add the service to `Client` struct in `client.go` 165 | 4. Initialize the service in `NewClient` 166 | 5. Add models to `models.go` if needed 167 | 6. Update `README.md` with usage examples 168 | 169 | ## Questions? 170 | 171 | Feel free to open an issue with your question or reach out to the maintainers. 172 | 173 | Thank you for contributing! 🙏 174 | -------------------------------------------------------------------------------- /errors_test.go: -------------------------------------------------------------------------------- 1 | package invoiceninja 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | ) 7 | 8 | func TestAPIErrorError(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | err *APIError 12 | expected string 13 | }{ 14 | { 15 | name: "with message", 16 | err: &APIError{StatusCode: 400, Message: "bad request"}, 17 | expected: "Invoice Ninja API error (status 400): bad request", 18 | }, 19 | { 20 | name: "without message", 21 | err: &APIError{StatusCode: 500}, 22 | expected: "Invoice Ninja API error (status 500)", 23 | }, 24 | } 25 | 26 | for _, tt := range tests { 27 | t.Run(tt.name, func(t *testing.T) { 28 | if got := tt.err.Error(); got != tt.expected { 29 | t.Errorf("Error() = %v, want %v", got, tt.expected) 30 | } 31 | }) 32 | } 33 | } 34 | 35 | func TestAPIErrorMethods(t *testing.T) { 36 | tests := []struct { 37 | name string 38 | err *APIError 39 | method func(*APIError) bool 40 | expected bool 41 | }{ 42 | { 43 | name: "IsNotFound true", 44 | err: &APIError{StatusCode: http.StatusNotFound}, 45 | method: (*APIError).IsNotFound, 46 | expected: true, 47 | }, 48 | { 49 | name: "IsNotFound false", 50 | err: &APIError{StatusCode: http.StatusOK}, 51 | method: (*APIError).IsNotFound, 52 | expected: false, 53 | }, 54 | { 55 | name: "IsUnauthorized true", 56 | err: &APIError{StatusCode: http.StatusUnauthorized}, 57 | method: (*APIError).IsUnauthorized, 58 | expected: true, 59 | }, 60 | { 61 | name: "IsForbidden true", 62 | err: &APIError{StatusCode: http.StatusForbidden}, 63 | method: (*APIError).IsForbidden, 64 | expected: true, 65 | }, 66 | { 67 | name: "IsValidationError true", 68 | err: &APIError{StatusCode: http.StatusUnprocessableEntity}, 69 | method: (*APIError).IsValidationError, 70 | expected: true, 71 | }, 72 | { 73 | name: "IsRateLimited true", 74 | err: &APIError{StatusCode: http.StatusTooManyRequests}, 75 | method: (*APIError).IsRateLimited, 76 | expected: true, 77 | }, 78 | { 79 | name: "IsServerError true", 80 | err: &APIError{StatusCode: 500}, 81 | method: (*APIError).IsServerError, 82 | expected: true, 83 | }, 84 | { 85 | name: "IsServerError true for 503", 86 | err: &APIError{StatusCode: 503}, 87 | method: (*APIError).IsServerError, 88 | expected: true, 89 | }, 90 | { 91 | name: "IsServerError false for 400", 92 | err: &APIError{StatusCode: 400}, 93 | method: (*APIError).IsServerError, 94 | expected: false, 95 | }, 96 | } 97 | 98 | for _, tt := range tests { 99 | t.Run(tt.name, func(t *testing.T) { 100 | if got := tt.method(tt.err); got != tt.expected { 101 | t.Errorf("%s() = %v, want %v", tt.name, got, tt.expected) 102 | } 103 | }) 104 | } 105 | } 106 | 107 | func TestParseAPIError(t *testing.T) { 108 | tests := []struct { 109 | name string 110 | statusCode int 111 | body []byte 112 | expectedMsg string 113 | expectedErrors map[string][]string 114 | }{ 115 | { 116 | name: "parse JSON error", 117 | statusCode: 422, 118 | body: []byte(`{"message":"Validation failed","errors":{"email":["Email is required"]}}`), 119 | expectedMsg: "Validation failed", 120 | expectedErrors: map[string][]string{ 121 | "email": {"Email is required"}, 122 | }, 123 | }, 124 | { 125 | name: "invalid JSON", 126 | statusCode: 400, 127 | body: []byte(`invalid json`), 128 | expectedMsg: "bad request", 129 | }, 130 | { 131 | name: "empty body 401", 132 | statusCode: 401, 133 | body: nil, 134 | expectedMsg: "unauthorized - check your API token", 135 | }, 136 | { 137 | name: "empty body 403", 138 | statusCode: 403, 139 | body: nil, 140 | expectedMsg: "forbidden - you don't have permission to access this resource", 141 | }, 142 | { 143 | name: "empty body 404", 144 | statusCode: 404, 145 | body: nil, 146 | expectedMsg: "resource not found", 147 | }, 148 | { 149 | name: "empty body 429", 150 | statusCode: 429, 151 | body: nil, 152 | expectedMsg: "rate limit exceeded", 153 | }, 154 | { 155 | name: "empty body 500", 156 | statusCode: 500, 157 | body: nil, 158 | expectedMsg: "server error", 159 | }, 160 | } 161 | 162 | for _, tt := range tests { 163 | t.Run(tt.name, func(t *testing.T) { 164 | err := parseAPIError(tt.statusCode, tt.body) 165 | 166 | if err.StatusCode != tt.statusCode { 167 | t.Errorf("StatusCode = %v, want %v", err.StatusCode, tt.statusCode) 168 | } 169 | 170 | if err.Message != tt.expectedMsg { 171 | t.Errorf("Message = %v, want %v", err.Message, tt.expectedMsg) 172 | } 173 | 174 | if tt.expectedErrors != nil { 175 | for k, v := range tt.expectedErrors { 176 | if err.Errors[k] == nil { 177 | t.Errorf("expected error for key %s", k) 178 | } else if err.Errors[k][0] != v[0] { 179 | t.Errorf("Errors[%s] = %v, want %v", k, err.Errors[k], v) 180 | } 181 | } 182 | } 183 | }) 184 | } 185 | } 186 | 187 | func TestIsAPIError(t *testing.T) { 188 | apiErr := &APIError{StatusCode: 400} 189 | 190 | got, ok := IsAPIError(apiErr) 191 | if !ok { 192 | t.Error("expected ok to be true") 193 | } 194 | if got != apiErr { 195 | t.Error("expected returned error to be same as input") 196 | } 197 | 198 | // Test with non-APIError 199 | var regularErr error = nil 200 | _, ok = IsAPIError(regularErr) 201 | if ok { 202 | t.Error("expected ok to be false for nil error") 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /payment_terms_test.go: -------------------------------------------------------------------------------- 1 | package invoiceninja 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | ) 10 | 11 | func TestPaymentTermsServiceList(t *testing.T) { 12 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 13 | if r.Method != "GET" { 14 | t.Errorf("expected GET method, got %s", r.Method) 15 | } 16 | if r.URL.Path != "/api/v1/payment_terms" { 17 | t.Errorf("expected path /api/v1/payment_terms, got %s", r.URL.Path) 18 | } 19 | 20 | w.Header().Set("Content-Type", "application/json") 21 | json.NewEncoder(w).Encode(map[string]interface{}{ 22 | "data": []map[string]interface{}{ 23 | {"id": "term1", "name": "Net 30", "num_days": 30}, 24 | {"id": "term2", "name": "Net 60", "num_days": 60}, 25 | }, 26 | }) 27 | })) 28 | defer server.Close() 29 | 30 | client := NewClient("test-token", WithBaseURL(server.URL)) 31 | 32 | resp, err := client.PaymentTerms.List(context.Background(), nil) 33 | if err != nil { 34 | t.Fatalf("unexpected error: %v", err) 35 | } 36 | 37 | if len(resp.Data) != 2 { 38 | t.Errorf("expected 2 payment terms, got %d", len(resp.Data)) 39 | } 40 | 41 | if resp.Data[0].Name != "Net 30" { 42 | t.Errorf("expected first term name to be 'Net 30', got '%s'", resp.Data[0].Name) 43 | } 44 | 45 | if resp.Data[0].NumDays != 30 { 46 | t.Errorf("expected first term num_days to be 30, got %d", resp.Data[0].NumDays) 47 | } 48 | } 49 | 50 | func TestPaymentTermsServiceGet(t *testing.T) { 51 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 52 | if r.URL.Path != "/api/v1/payment_terms/term1" { 53 | t.Errorf("expected path /api/v1/payment_terms/term1, got %s", r.URL.Path) 54 | } 55 | 56 | w.Header().Set("Content-Type", "application/json") 57 | json.NewEncoder(w).Encode(map[string]interface{}{ 58 | "data": map[string]interface{}{ 59 | "id": "term1", 60 | "name": "Net 30", 61 | "num_days": 30, 62 | }, 63 | }) 64 | })) 65 | defer server.Close() 66 | 67 | client := NewClient("test-token", WithBaseURL(server.URL)) 68 | 69 | term, err := client.PaymentTerms.Get(context.Background(), "term1") 70 | if err != nil { 71 | t.Fatalf("unexpected error: %v", err) 72 | } 73 | 74 | if term.ID != "term1" { 75 | t.Errorf("expected term ID to be 'term1', got '%s'", term.ID) 76 | } 77 | } 78 | 79 | func TestPaymentTermsServiceCreate(t *testing.T) { 80 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 81 | if r.Method != "POST" { 82 | t.Errorf("expected POST method, got %s", r.Method) 83 | } 84 | 85 | var body PaymentTerm 86 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 87 | t.Errorf("failed to decode request body: %v", err) 88 | } 89 | 90 | if body.Name != "Net 45" { 91 | t.Errorf("expected name to be 'Net 45', got '%s'", body.Name) 92 | } 93 | 94 | w.Header().Set("Content-Type", "application/json") 95 | json.NewEncoder(w).Encode(map[string]interface{}{ 96 | "data": map[string]interface{}{ 97 | "id": "newterm", 98 | "name": "Net 45", 99 | "num_days": 45, 100 | }, 101 | }) 102 | })) 103 | defer server.Close() 104 | 105 | client := NewClient("test-token", WithBaseURL(server.URL)) 106 | 107 | term, err := client.PaymentTerms.Create(context.Background(), &PaymentTerm{ 108 | Name: "Net 45", 109 | NumDays: 45, 110 | }) 111 | if err != nil { 112 | t.Fatalf("unexpected error: %v", err) 113 | } 114 | 115 | if term.ID != "newterm" { 116 | t.Errorf("expected term ID to be 'newterm', got '%s'", term.ID) 117 | } 118 | } 119 | 120 | func TestPaymentTermsServiceUpdate(t *testing.T) { 121 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 122 | if r.Method != "PUT" { 123 | t.Errorf("expected PUT method, got %s", r.Method) 124 | } 125 | 126 | w.Header().Set("Content-Type", "application/json") 127 | json.NewEncoder(w).Encode(map[string]interface{}{ 128 | "data": map[string]interface{}{ 129 | "id": "term1", 130 | "name": "Updated Term", 131 | "num_days": 90, 132 | }, 133 | }) 134 | })) 135 | defer server.Close() 136 | 137 | client := NewClient("test-token", WithBaseURL(server.URL)) 138 | 139 | term, err := client.PaymentTerms.Update(context.Background(), "term1", &PaymentTerm{ 140 | Name: "Updated Term", 141 | NumDays: 90, 142 | }) 143 | if err != nil { 144 | t.Fatalf("unexpected error: %v", err) 145 | } 146 | 147 | if term.Name != "Updated Term" { 148 | t.Errorf("expected term name to be 'Updated Term', got '%s'", term.Name) 149 | } 150 | } 151 | 152 | func TestPaymentTermsServiceDelete(t *testing.T) { 153 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 154 | if r.Method != "DELETE" { 155 | t.Errorf("expected DELETE method, got %s", r.Method) 156 | } 157 | w.WriteHeader(http.StatusOK) 158 | })) 159 | defer server.Close() 160 | 161 | client := NewClient("test-token", WithBaseURL(server.URL)) 162 | 163 | err := client.PaymentTerms.Delete(context.Background(), "term1") 164 | if err != nil { 165 | t.Fatalf("unexpected error: %v", err) 166 | } 167 | } 168 | 169 | func TestPaymentTermListOptionsToQuery(t *testing.T) { 170 | opts := &PaymentTermListOptions{ 171 | PerPage: 10, 172 | Page: 2, 173 | Include: "company", 174 | } 175 | 176 | q := opts.toQuery() 177 | 178 | if q.Get("per_page") != "10" { 179 | t.Errorf("expected per_page=10, got %s", q.Get("per_page")) 180 | } 181 | if q.Get("page") != "2" { 182 | t.Errorf("expected page=2, got %s", q.Get("page")) 183 | } 184 | if q.Get("include") != "company" { 185 | t.Errorf("expected include=company, got %s", q.Get("include")) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [conduct@ln.software](mailto:conduct@ln.software). 64 | 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /files.go: -------------------------------------------------------------------------------- 1 | package invoiceninja 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "mime/multipart" 9 | "net/http" 10 | "os" 11 | "path/filepath" 12 | ) 13 | 14 | // DownloadsService handles file download operations. 15 | type DownloadsService struct { 16 | client *Client 17 | } 18 | 19 | // DownloadInvoicePDF downloads an invoice PDF by invitation key. 20 | func (s *DownloadsService) DownloadInvoicePDF(ctx context.Context, invitationKey string) ([]byte, error) { 21 | return s.downloadFile(ctx, fmt.Sprintf("/api/v1/invoice/%s/download", invitationKey)) 22 | } 23 | 24 | // DownloadInvoiceDeliveryNote downloads an invoice delivery note PDF. 25 | func (s *DownloadsService) DownloadInvoiceDeliveryNote(ctx context.Context, invoiceID string) ([]byte, error) { 26 | return s.downloadFile(ctx, fmt.Sprintf("/api/v1/invoices/%s/delivery_note", invoiceID)) 27 | } 28 | 29 | // DownloadCreditPDF downloads a credit PDF by invitation key. 30 | func (s *DownloadsService) DownloadCreditPDF(ctx context.Context, invitationKey string) ([]byte, error) { 31 | return s.downloadFile(ctx, fmt.Sprintf("/api/v1/credit/%s/download", invitationKey)) 32 | } 33 | 34 | // DownloadQuotePDF downloads a quote PDF by invitation key. 35 | func (s *DownloadsService) DownloadQuotePDF(ctx context.Context, invitationKey string) ([]byte, error) { 36 | return s.downloadFile(ctx, fmt.Sprintf("/api/v1/quote/%s/download", invitationKey)) 37 | } 38 | 39 | // downloadFile performs a file download request. 40 | func (s *DownloadsService) downloadFile(ctx context.Context, path string) ([]byte, error) { 41 | req, err := http.NewRequestWithContext(ctx, "GET", s.client.baseURL+path, nil) 42 | if err != nil { 43 | return nil, fmt.Errorf("failed to create request: %w", err) 44 | } 45 | 46 | req.Header.Set("X-API-TOKEN", s.client.apiToken) 47 | req.Header.Set("X-Requested-With", "XMLHttpRequest") 48 | req.Header.Set("Accept", "application/pdf") 49 | 50 | resp, err := s.client.httpClient.Do(req) 51 | if err != nil { 52 | return nil, fmt.Errorf("request failed: %w", err) 53 | } 54 | defer resp.Body.Close() 55 | 56 | if resp.StatusCode >= 400 { 57 | body, _ := io.ReadAll(resp.Body) 58 | return nil, parseAPIError(resp.StatusCode, body) 59 | } 60 | 61 | return io.ReadAll(resp.Body) 62 | } 63 | 64 | // UploadsService handles file upload operations. 65 | type UploadsService struct { 66 | client *Client 67 | } 68 | 69 | // UploadDocument uploads a document to an entity. 70 | func (s *UploadsService) UploadDocument(ctx context.Context, entityType, entityID string, filePath string) error { 71 | return s.uploadFile(ctx, fmt.Sprintf("/api/v1/%s/%s/upload", entityType, entityID), filePath) 72 | } 73 | 74 | // UploadInvoiceDocument uploads a document to an invoice. 75 | func (s *UploadsService) UploadInvoiceDocument(ctx context.Context, invoiceID string, filePath string) error { 76 | return s.uploadFile(ctx, fmt.Sprintf("/api/v1/invoices/%s/upload", invoiceID), filePath) 77 | } 78 | 79 | // UploadPaymentDocument uploads a document to a payment. 80 | func (s *UploadsService) UploadPaymentDocument(ctx context.Context, paymentID string, filePath string) error { 81 | return s.uploadFile(ctx, fmt.Sprintf("/api/v1/payments/%s/upload", paymentID), filePath) 82 | } 83 | 84 | // UploadClientDocument uploads a document to a client. 85 | func (s *UploadsService) UploadClientDocument(ctx context.Context, clientID string, filePath string) error { 86 | return s.uploadFile(ctx, fmt.Sprintf("/api/v1/clients/%s/upload", clientID), filePath) 87 | } 88 | 89 | // UploadCreditDocument uploads a document to a credit. 90 | func (s *UploadsService) UploadCreditDocument(ctx context.Context, creditID string, filePath string) error { 91 | return s.uploadFile(ctx, fmt.Sprintf("/api/v1/credits/%s/upload", creditID), filePath) 92 | } 93 | 94 | // UploadDocumentFromReader uploads a document from an io.Reader. 95 | func (s *UploadsService) UploadDocumentFromReader(ctx context.Context, entityType, entityID, filename string, reader io.Reader) error { 96 | return s.uploadFromReader(ctx, fmt.Sprintf("/api/v1/%s/%s/upload", entityType, entityID), filename, reader) 97 | } 98 | 99 | // uploadFile uploads a file from the filesystem. 100 | func (s *UploadsService) uploadFile(ctx context.Context, path, filePath string) error { 101 | file, err := os.Open(filePath) 102 | if err != nil { 103 | return fmt.Errorf("failed to open file: %w", err) 104 | } 105 | defer file.Close() 106 | 107 | return s.uploadFromReader(ctx, path, filepath.Base(filePath), file) 108 | } 109 | 110 | // uploadFromReader uploads a file from an io.Reader. 111 | func (s *UploadsService) uploadFromReader(ctx context.Context, path, filename string, reader io.Reader) error { 112 | var buf bytes.Buffer 113 | writer := multipart.NewWriter(&buf) 114 | 115 | // Add _method field for PUT override 116 | if err := writer.WriteField("_method", "PUT"); err != nil { 117 | return fmt.Errorf("failed to write method field: %w", err) 118 | } 119 | 120 | // Create form file 121 | part, err := writer.CreateFormFile("documents[]", filename) 122 | if err != nil { 123 | return fmt.Errorf("failed to create form file: %w", err) 124 | } 125 | 126 | if _, copyErr := io.Copy(part, reader); copyErr != nil { 127 | return fmt.Errorf("failed to copy file content: %w", copyErr) 128 | } 129 | 130 | if closeErr := writer.Close(); closeErr != nil { 131 | return fmt.Errorf("failed to close multipart writer: %w", closeErr) 132 | } 133 | 134 | req, err := http.NewRequestWithContext(ctx, "POST", s.client.baseURL+path, &buf) 135 | if err != nil { 136 | return fmt.Errorf("failed to create request: %w", err) 137 | } 138 | 139 | req.Header.Set("X-API-TOKEN", s.client.apiToken) 140 | req.Header.Set("X-Requested-With", "XMLHttpRequest") 141 | req.Header.Set("Content-Type", writer.FormDataContentType()) 142 | 143 | resp, err := s.client.httpClient.Do(req) 144 | if err != nil { 145 | return fmt.Errorf("request failed: %w", err) 146 | } 147 | defer resp.Body.Close() 148 | 149 | if resp.StatusCode >= 400 { 150 | body, _ := io.ReadAll(resp.Body) 151 | return parseAPIError(resp.StatusCode, body) 152 | } 153 | 154 | return nil 155 | } 156 | -------------------------------------------------------------------------------- /docs/error-handling.md: -------------------------------------------------------------------------------- 1 | # Error Handling 2 | 3 | This guide covers error handling strategies when using the Go Invoice Ninja SDK. 4 | 5 | ## Error Types 6 | 7 | ### APIError 8 | 9 | All API errors are returned as `*invoiceninja.APIError`: 10 | 11 | ```go 12 | type APIError struct { 13 | StatusCode int // HTTP status code 14 | Message string // Error message 15 | Errors map[string][]string // Field-specific validation errors 16 | } 17 | ``` 18 | 19 | ### Checking Error Types 20 | 21 | ```go 22 | payments, err := client.Payments.List(ctx, nil) 23 | if err != nil { 24 | if apiErr, ok := err.(*invoiceninja.APIError); ok { 25 | // Handle API-specific error 26 | fmt.Printf("Status: %d, Message: %s\n", apiErr.StatusCode, apiErr.Message) 27 | } else { 28 | // Handle other errors (network, etc.) 29 | fmt.Printf("Error: %v\n", err) 30 | } 31 | } 32 | ``` 33 | 34 | ## Error Helper Methods 35 | 36 | The `APIError` type provides helper methods for common error types: 37 | 38 | ```go 39 | if apiErr, ok := err.(*invoiceninja.APIError); ok { 40 | switch { 41 | case apiErr.IsNotFound(): 42 | // 404 - Resource not found 43 | log.Printf("Resource not found") 44 | 45 | case apiErr.IsUnauthorized(): 46 | // 401 - Invalid or missing API token 47 | log.Fatal("Please check your API token") 48 | 49 | case apiErr.IsForbidden(): 50 | // 403 - Insufficient permissions 51 | log.Fatal("Access denied") 52 | 53 | case apiErr.IsValidationError(): 54 | // 422 - Validation failed 55 | for field, errors := range apiErr.Errors { 56 | log.Printf("Field %s: %v", field, errors) 57 | } 58 | 59 | case apiErr.IsRateLimited(): 60 | // 429 - Too many requests 61 | log.Println("Rate limited, waiting...") 62 | time.Sleep(time.Minute) 63 | 64 | case apiErr.IsServerError(): 65 | // 5xx - Server error 66 | log.Println("Server error, please try again later") 67 | } 68 | } 69 | ``` 70 | 71 | ## Handling Validation Errors 72 | 73 | Validation errors (422) include field-specific error messages: 74 | 75 | ```go 76 | payment, err := client.Payments.Create(ctx, &invoiceninja.PaymentRequest{ 77 | Amount: -100, // Invalid! 78 | }) 79 | if err != nil { 80 | if apiErr, ok := err.(*invoiceninja.APIError); ok && apiErr.IsValidationError() { 81 | for field, errors := range apiErr.Errors { 82 | for _, e := range errors { 83 | fmt.Printf("Validation error on %s: %s\n", field, e) 84 | } 85 | } 86 | } 87 | } 88 | ``` 89 | 90 | ## Retry Configuration 91 | 92 | The SDK includes automatic retry for transient errors: 93 | 94 | ```go 95 | retryConfig := &invoiceninja.RetryConfig{ 96 | MaxRetries: 3, 97 | InitialBackoff: time.Second, 98 | MaxBackoff: 30 * time.Second, 99 | BackoffMultiplier: 2.0, 100 | RetryOnStatusCodes: []int{429, 500, 502, 503, 504}, 101 | Jitter: true, 102 | } 103 | 104 | client := invoiceninja.NewClient("token", 105 | invoiceninja.WithRetryConfig(retryConfig)) 106 | ``` 107 | 108 | ### Default Retry Behavior 109 | 110 | By default, the SDK retries on: 111 | - **429** - Rate limit exceeded 112 | - **500** - Internal server error 113 | - **502** - Bad gateway 114 | - **503** - Service unavailable 115 | - **504** - Gateway timeout 116 | 117 | With exponential backoff: 1s → 2s → 4s (with jitter) 118 | 119 | ## Rate Limiting 120 | 121 | ### Server-Side Rate Limits 122 | 123 | Invoice Ninja enforces rate limits. When exceeded, you'll receive a 429 error: 124 | 125 | ```go 126 | if apiErr.IsRateLimited() { 127 | // Check for Retry-After header 128 | retryAfter := apiErr.Headers.Get("Retry-After") 129 | if retryAfter != "" { 130 | duration, _ := strconv.Atoi(retryAfter) 131 | time.Sleep(time.Duration(duration) * time.Second) 132 | } 133 | } 134 | ``` 135 | 136 | ### Client-Side Rate Limiting 137 | 138 | Prevent hitting rate limits with client-side limiting: 139 | 140 | ```go 141 | // Limit to 10 requests per second 142 | rateLimiter := invoiceninja.NewRateLimiter(10) 143 | 144 | client := invoiceninja.NewClient("token", 145 | invoiceninja.WithRateLimiter(rateLimiter)) 146 | ``` 147 | 148 | ## Context and Timeouts 149 | 150 | Always use context for cancellation and timeouts: 151 | 152 | ```go 153 | // With timeout 154 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 155 | defer cancel() 156 | 157 | payments, err := client.Payments.List(ctx, nil) 158 | if err != nil { 159 | if errors.Is(err, context.DeadlineExceeded) { 160 | log.Println("Request timed out") 161 | } 162 | if errors.Is(err, context.Canceled) { 163 | log.Println("Request was canceled") 164 | } 165 | } 166 | ``` 167 | 168 | ## Best Practices 169 | 170 | ### 1. Always Check Errors 171 | 172 | ```go 173 | // Bad 174 | payments, _ := client.Payments.List(ctx, nil) 175 | 176 | // Good 177 | payments, err := client.Payments.List(ctx, nil) 178 | if err != nil { 179 | return fmt.Errorf("listing payments: %w", err) 180 | } 181 | ``` 182 | 183 | ### 2. Use Error Wrapping 184 | 185 | ```go 186 | invoice, err := client.Invoices.Get(ctx, invoiceID) 187 | if err != nil { 188 | return nil, fmt.Errorf("fetching invoice %s: %w", invoiceID, err) 189 | } 190 | ``` 191 | 192 | ### 3. Log Contextual Information 193 | 194 | ```go 195 | if apiErr, ok := err.(*invoiceninja.APIError); ok { 196 | log.Printf("API error: status=%d message=%s endpoint=%s", 197 | apiErr.StatusCode, 198 | apiErr.Message, 199 | "/api/v1/payments", 200 | ) 201 | } 202 | ``` 203 | 204 | ### 4. Implement Circuit Breakers for Critical Paths 205 | 206 | For production systems, consider implementing circuit breakers: 207 | 208 | ```go 209 | // Pseudo-code with a circuit breaker library 210 | breaker := circuitbreaker.New(circuitbreaker.Config{ 211 | MaxFailures: 3, 212 | Timeout: time.Minute, 213 | }) 214 | 215 | result, err := breaker.Execute(func() (interface{}, error) { 216 | return client.Payments.List(ctx, nil) 217 | }) 218 | ``` 219 | -------------------------------------------------------------------------------- /invoices.go: -------------------------------------------------------------------------------- 1 | package invoiceninja 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "strconv" 8 | ) 9 | 10 | // InvoicesService handles invoice-related API operations. 11 | type InvoicesService struct { 12 | client *Client 13 | } 14 | 15 | // InvoiceListOptions specifies the optional parameters for listing invoices. 16 | type InvoiceListOptions struct { 17 | // PerPage is the number of results per page (default 20). 18 | PerPage int 19 | 20 | // Page is the page number. 21 | Page int 22 | 23 | // Filter searches across multiple fields. 24 | Filter string 25 | 26 | // ClientID filters by client. 27 | ClientID string 28 | 29 | // Status filters by status (comma-separated: active, archived, deleted). 30 | Status string 31 | 32 | // CreatedAt filters by creation date. 33 | CreatedAt string 34 | 35 | // UpdatedAt filters by update date. 36 | UpdatedAt string 37 | 38 | // IsDeleted filters by deleted status. 39 | IsDeleted *bool 40 | 41 | // Sort specifies the sort order (e.g., "id|desc", "number|asc"). 42 | Sort string 43 | 44 | // Include specifies related entities to include. 45 | Include string 46 | } 47 | 48 | // toQuery converts options to URL query parameters. 49 | func (o *InvoiceListOptions) toQuery() url.Values { 50 | if o == nil { 51 | return nil 52 | } 53 | 54 | q := url.Values{} 55 | 56 | if o.PerPage > 0 { 57 | q.Set("per_page", strconv.Itoa(o.PerPage)) 58 | } 59 | if o.Page > 0 { 60 | q.Set("page", strconv.Itoa(o.Page)) 61 | } 62 | if o.Filter != "" { 63 | q.Set("filter", o.Filter) 64 | } 65 | if o.ClientID != "" { 66 | q.Set("client_id", o.ClientID) 67 | } 68 | if o.Status != "" { 69 | q.Set("status", o.Status) 70 | } 71 | if o.CreatedAt != "" { 72 | q.Set("created_at", o.CreatedAt) 73 | } 74 | if o.UpdatedAt != "" { 75 | q.Set("updated_at", o.UpdatedAt) 76 | } 77 | if o.IsDeleted != nil { 78 | q.Set("is_deleted", strconv.FormatBool(*o.IsDeleted)) 79 | } 80 | if o.Sort != "" { 81 | q.Set("sort", o.Sort) 82 | } 83 | if o.Include != "" { 84 | q.Set("include", o.Include) 85 | } 86 | 87 | return q 88 | } 89 | 90 | // List retrieves a list of invoices. 91 | func (s *InvoicesService) List(ctx context.Context, opts *InvoiceListOptions) (*ListResponse[Invoice], error) { 92 | var resp ListResponse[Invoice] 93 | if err := s.client.doRequest(ctx, "GET", "/api/v1/invoices", opts.toQuery(), nil, &resp); err != nil { 94 | return nil, err 95 | } 96 | return &resp, nil 97 | } 98 | 99 | // Get retrieves a single invoice by ID. 100 | func (s *InvoicesService) Get(ctx context.Context, id string) (*Invoice, error) { 101 | var resp SingleResponse[Invoice] 102 | if err := s.client.doRequest(ctx, "GET", fmt.Sprintf("/api/v1/invoices/%s", id), nil, nil, &resp); err != nil { 103 | return nil, err 104 | } 105 | return &resp.Data, nil 106 | } 107 | 108 | // Create creates a new invoice. 109 | func (s *InvoicesService) Create(ctx context.Context, invoice *Invoice) (*Invoice, error) { 110 | var resp SingleResponse[Invoice] 111 | if err := s.client.doRequest(ctx, "POST", "/api/v1/invoices", nil, invoice, &resp); err != nil { 112 | return nil, err 113 | } 114 | return &resp.Data, nil 115 | } 116 | 117 | // Update updates an existing invoice. 118 | func (s *InvoicesService) Update(ctx context.Context, id string, invoice *Invoice) (*Invoice, error) { 119 | var resp SingleResponse[Invoice] 120 | if err := s.client.doRequest(ctx, "PUT", fmt.Sprintf("/api/v1/invoices/%s", id), nil, invoice, &resp); err != nil { 121 | return nil, err 122 | } 123 | return &resp.Data, nil 124 | } 125 | 126 | // Delete deletes an invoice by ID. 127 | func (s *InvoicesService) Delete(ctx context.Context, id string) error { 128 | return s.client.doRequest(ctx, "DELETE", fmt.Sprintf("/api/v1/invoices/%s", id), nil, nil, nil) 129 | } 130 | 131 | // Archive archives an invoice. 132 | func (s *InvoicesService) Archive(ctx context.Context, id string) (*Invoice, error) { 133 | return s.bulkAction(ctx, "archive", id) 134 | } 135 | 136 | // Restore restores an archived invoice. 137 | func (s *InvoicesService) Restore(ctx context.Context, id string) (*Invoice, error) { 138 | return s.bulkAction(ctx, "restore", id) 139 | } 140 | 141 | // MarkPaid marks an invoice as paid. 142 | func (s *InvoicesService) MarkPaid(ctx context.Context, id string) (*Invoice, error) { 143 | return s.bulkAction(ctx, "mark_paid", id) 144 | } 145 | 146 | // MarkSent marks an invoice as sent. 147 | func (s *InvoicesService) MarkSent(ctx context.Context, id string) (*Invoice, error) { 148 | return s.bulkAction(ctx, "mark_sent", id) 149 | } 150 | 151 | // Email sends an invoice via email. 152 | func (s *InvoicesService) Email(ctx context.Context, id string) (*Invoice, error) { 153 | return s.bulkAction(ctx, "email", id) 154 | } 155 | 156 | // Bulk performs a bulk action on multiple invoices. 157 | func (s *InvoicesService) Bulk(ctx context.Context, action string, ids []string) ([]Invoice, error) { 158 | req := BulkAction{ 159 | Action: action, 160 | IDs: ids, 161 | } 162 | 163 | var resp ListResponse[Invoice] 164 | if err := s.client.doRequest(ctx, "POST", "/api/v1/invoices/bulk", nil, req, &resp); err != nil { 165 | return nil, err 166 | } 167 | return resp.Data, nil 168 | } 169 | 170 | // bulkAction performs a single-item bulk action. 171 | func (s *InvoicesService) bulkAction(ctx context.Context, action, id string) (*Invoice, error) { 172 | invoices, err := s.Bulk(ctx, action, []string{id}) 173 | if err != nil { 174 | return nil, err 175 | } 176 | if len(invoices) == 0 { 177 | return nil, fmt.Errorf("no invoice returned from bulk action") 178 | } 179 | return &invoices[0], nil 180 | } 181 | 182 | // GetBlank retrieves a blank invoice object with default values. 183 | func (s *InvoicesService) GetBlank(ctx context.Context) (*Invoice, error) { 184 | var resp SingleResponse[Invoice] 185 | if err := s.client.doRequest(ctx, "GET", "/api/v1/invoices/create", nil, nil, &resp); err != nil { 186 | return nil, err 187 | } 188 | return &resp.Data, nil 189 | } 190 | 191 | // Download downloads an invoice PDF. 192 | func (s *InvoicesService) Download(ctx context.Context, invitationKey string) ([]byte, error) { 193 | // This would need special handling for binary response 194 | // For now, we'll return the raw bytes 195 | return nil, fmt.Errorf("not implemented - use client.Request with custom handling") 196 | } 197 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We actively support the following versions of the Go Invoice Ninja SDK with security updates: 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | Latest | :white_check_mark: | 10 | | < 1.0 | :x: | 11 | 12 | We recommend always using the latest version of the SDK to ensure you have the most recent security updates and bug fixes. 13 | 14 | ## Reporting a Vulnerability 15 | 16 | The Go Invoice Ninja SDK team takes security vulnerabilities seriously. We appreciate your efforts to responsibly disclose your findings. 17 | 18 | ### How to Report 19 | 20 | **Please do not report security vulnerabilities through public GitHub issues.** 21 | 22 | Instead, please report security vulnerabilities by emailing: 23 | 24 | **[security@ln.software](mailto:security@ln.software)** 25 | 26 | Alternatively, you can use GitHub's private vulnerability reporting feature: 27 | 1. Go to the [Security tab](https://github.com/AshkanYarmoradi/go-invoice-ninja/security) 28 | 2. Click "Report a vulnerability" 29 | 3. Fill out the form with details about the vulnerability 30 | 31 | ### What to Include 32 | 33 | To help us better understand and resolve the issue, please include as much of the following information as possible: 34 | 35 | - Type of vulnerability (e.g., authentication bypass, information disclosure, etc.) 36 | - Full paths of source file(s) related to the vulnerability 37 | - The location of the affected source code (tag/branch/commit or direct URL) 38 | - Step-by-step instructions to reproduce the issue 39 | - Proof-of-concept or exploit code (if possible) 40 | - Impact of the issue, including how an attacker might exploit it 41 | - Any suggested fixes or mitigation strategies 42 | 43 | ### What to Expect 44 | 45 | - **Initial Response**: We will acknowledge receipt of your vulnerability report within 48 hours 46 | - **Status Updates**: We will send you regular updates about our progress (at least every 5 business days) 47 | - **Validation**: We will work to validate and reproduce the issue 48 | - **Resolution Timeline**: We aim to resolve critical vulnerabilities within 30 days 49 | - **Disclosure**: Once fixed, we will coordinate with you on public disclosure timing 50 | 51 | ### Disclosure Policy 52 | 53 | - We request that you give us reasonable time to address the issue before public disclosure 54 | - We will credit you in our security advisory (unless you prefer to remain anonymous) 55 | - We will create a security advisory on GitHub for confirmed vulnerabilities 56 | - We will release a patch as soon as possible and notify users through GitHub releases 57 | 58 | ## Security Best Practices 59 | 60 | When using the Go Invoice Ninja SDK, we recommend following these security best practices: 61 | 62 | ### API Token Management 63 | 64 | - **Never commit API tokens** to version control 65 | - Store tokens in environment variables or secure secret management systems 66 | - Rotate tokens regularly 67 | - Use separate tokens for development, staging, and production environments 68 | - Revoke tokens immediately if they are compromised 69 | 70 | ### HTTPS/TLS 71 | 72 | - Always use HTTPS endpoints when communicating with Invoice Ninja API 73 | - For self-hosted instances, ensure valid TLS certificates are configured 74 | - Never disable TLS certificate verification in production 75 | 76 | ### Webhook Security 77 | 78 | - Always verify webhook signatures using the built-in verification methods 79 | - Use HTTPS endpoints for webhook receivers 80 | - Implement rate limiting for webhook endpoints 81 | - Log and monitor webhook requests for unusual activity 82 | 83 | ### Dependencies 84 | 85 | - Keep the SDK updated to the latest version 86 | - Regularly audit your dependencies using `go list -m all | nancy sleuth` 87 | - Monitor GitHub security advisories for this repository 88 | 89 | ### Code Security 90 | 91 | ```go 92 | // ✅ Good: Using environment variables 93 | token := os.Getenv("INVOICE_NINJA_TOKEN") 94 | client := invoiceninja.NewClient(token) 95 | 96 | // ❌ Bad: Hardcoded credentials 97 | client := invoiceninja.NewClient("your-secret-token-here") 98 | ``` 99 | 100 | ```go 101 | // ✅ Good: Verify webhook signatures 102 | if !invoiceninja.VerifyWebhookSignature(payload, signature, secret) { 103 | return errors.New("invalid webhook signature") 104 | } 105 | 106 | // ❌ Bad: Process webhooks without verification 107 | // Don't trust incoming webhook data without verification 108 | ``` 109 | 110 | ## Security Updates 111 | 112 | Security updates will be released as follows: 113 | 114 | 1. **Critical vulnerabilities**: Immediate patch release 115 | 2. **High severity**: Patch within 7 days 116 | 3. **Medium severity**: Patch within 30 days 117 | 4. **Low severity**: Included in next regular release 118 | 119 | Security updates will be announced through: 120 | - GitHub Security Advisories 121 | - GitHub Releases with security tags 122 | - Repository README (if critical) 123 | 124 | ## Known Security Considerations 125 | 126 | ### Rate Limiting 127 | 128 | This SDK implements client-side rate limiting, but it should not be relied upon as the sole protection mechanism. Implement your own rate limiting at the application level and monitor for abuse. 129 | 130 | ### Error Messages 131 | 132 | Be cautious about logging error messages in production environments as they may contain sensitive information. Use structured logging and sanitize error messages before displaying them to end users. 133 | 134 | ### Data Handling 135 | 136 | - The SDK transmits data to Invoice Ninja's API in plain text (over HTTPS) 137 | - Ensure sensitive data is handled according to your organization's security policies 138 | - Be aware of data residency requirements when using cloud-hosted Invoice Ninja 139 | 140 | ## Scope 141 | 142 | This security policy applies to: 143 | - The Go Invoice Ninja SDK source code in this repository 144 | - Official releases and distributions 145 | 146 | This policy does **not** cover: 147 | - Invoice Ninja application itself (report to Invoice Ninja team) 148 | - Third-party applications using this SDK 149 | - User's implementation code 150 | 151 | ## Contact 152 | 153 | For security-related questions that are not vulnerability reports, you can: 154 | - Open a discussion on GitHub Discussions 155 | - Email: [security@ln.software](mailto:security@ln.software) 156 | 157 | --- 158 | 159 | Thank you for helping keep the Go Invoice Ninja SDK and its users secure! 160 | -------------------------------------------------------------------------------- /payments.go: -------------------------------------------------------------------------------- 1 | package invoiceninja 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "strconv" 8 | ) 9 | 10 | // PaymentsService handles payment-related API operations. 11 | type PaymentsService struct { 12 | client *Client 13 | } 14 | 15 | // PaymentListOptions specifies the optional parameters for listing payments. 16 | type PaymentListOptions struct { 17 | // PerPage is the number of results per page (default 20). 18 | PerPage int 19 | 20 | // Page is the page number. 21 | Page int 22 | 23 | // Filter searches across amount, date, and custom values. 24 | Filter string 25 | 26 | // Number searches by payment number. 27 | Number string 28 | 29 | // ClientID filters by client. 30 | ClientID string 31 | 32 | // Status filters by status (comma-separated: active, archived, deleted). 33 | Status string 34 | 35 | // CreatedAt filters by creation date. 36 | CreatedAt string 37 | 38 | // UpdatedAt filters by update date. 39 | UpdatedAt string 40 | 41 | // IsDeleted filters by deleted status. 42 | IsDeleted *bool 43 | 44 | // VendorID filters by vendor. 45 | VendorID string 46 | 47 | // Sort specifies the sort order (e.g., "id|desc", "number|asc"). 48 | Sort string 49 | 50 | // Include specifies related entities to include. 51 | Include string 52 | } 53 | 54 | // toQuery converts options to URL query parameters. 55 | func (o *PaymentListOptions) toQuery() url.Values { 56 | if o == nil { 57 | return nil 58 | } 59 | 60 | q := url.Values{} 61 | 62 | if o.PerPage > 0 { 63 | q.Set("per_page", strconv.Itoa(o.PerPage)) 64 | } 65 | if o.Page > 0 { 66 | q.Set("page", strconv.Itoa(o.Page)) 67 | } 68 | if o.Filter != "" { 69 | q.Set("filter", o.Filter) 70 | } 71 | if o.Number != "" { 72 | q.Set("number", o.Number) 73 | } 74 | if o.ClientID != "" { 75 | q.Set("client_id", o.ClientID) 76 | } 77 | if o.Status != "" { 78 | q.Set("status", o.Status) 79 | } 80 | if o.CreatedAt != "" { 81 | q.Set("created_at", o.CreatedAt) 82 | } 83 | if o.UpdatedAt != "" { 84 | q.Set("updated_at", o.UpdatedAt) 85 | } 86 | if o.IsDeleted != nil { 87 | q.Set("is_deleted", strconv.FormatBool(*o.IsDeleted)) 88 | } 89 | if o.VendorID != "" { 90 | q.Set("vendor_id", o.VendorID) 91 | } 92 | if o.Sort != "" { 93 | q.Set("sort", o.Sort) 94 | } 95 | if o.Include != "" { 96 | q.Set("include", o.Include) 97 | } 98 | 99 | return q 100 | } 101 | 102 | // List retrieves a list of payments. 103 | func (s *PaymentsService) List(ctx context.Context, opts *PaymentListOptions) (*ListResponse[Payment], error) { 104 | var resp ListResponse[Payment] 105 | if err := s.client.doRequest(ctx, "GET", "/api/v1/payments", opts.toQuery(), nil, &resp); err != nil { 106 | return nil, err 107 | } 108 | return &resp, nil 109 | } 110 | 111 | // Get retrieves a single payment by ID. 112 | func (s *PaymentsService) Get(ctx context.Context, id string) (*Payment, error) { 113 | var resp SingleResponse[Payment] 114 | if err := s.client.doRequest(ctx, "GET", fmt.Sprintf("/api/v1/payments/%s", id), nil, nil, &resp); err != nil { 115 | return nil, err 116 | } 117 | return &resp.Data, nil 118 | } 119 | 120 | // Create creates a new payment. 121 | func (s *PaymentsService) Create(ctx context.Context, payment *PaymentRequest) (*Payment, error) { 122 | var resp SingleResponse[Payment] 123 | if err := s.client.doRequest(ctx, "POST", "/api/v1/payments", nil, payment, &resp); err != nil { 124 | return nil, err 125 | } 126 | return &resp.Data, nil 127 | } 128 | 129 | // CreateWithEmailReceipt creates a new payment and optionally sends an email receipt. 130 | func (s *PaymentsService) CreateWithEmailReceipt(ctx context.Context, payment *PaymentRequest, sendEmail bool) (*Payment, error) { 131 | q := url.Values{} 132 | q.Set("email_receipt", strconv.FormatBool(sendEmail)) 133 | 134 | var resp SingleResponse[Payment] 135 | if err := s.client.doRequest(ctx, "POST", "/api/v1/payments", q, payment, &resp); err != nil { 136 | return nil, err 137 | } 138 | return &resp.Data, nil 139 | } 140 | 141 | // Update updates an existing payment. 142 | func (s *PaymentsService) Update(ctx context.Context, id string, payment *PaymentRequest) (*Payment, error) { 143 | var resp SingleResponse[Payment] 144 | if err := s.client.doRequest(ctx, "PUT", fmt.Sprintf("/api/v1/payments/%s", id), nil, payment, &resp); err != nil { 145 | return nil, err 146 | } 147 | return &resp.Data, nil 148 | } 149 | 150 | // Delete deletes a payment by ID. 151 | func (s *PaymentsService) Delete(ctx context.Context, id string) error { 152 | return s.client.doRequest(ctx, "DELETE", fmt.Sprintf("/api/v1/payments/%s", id), nil, nil, nil) 153 | } 154 | 155 | // Refund creates a refund for a payment. 156 | func (s *PaymentsService) Refund(ctx context.Context, refund *RefundRequest) (*Payment, error) { 157 | var resp SingleResponse[Payment] 158 | if err := s.client.doRequest(ctx, "POST", "/api/v1/payments/refund", nil, refund, &resp); err != nil { 159 | return nil, err 160 | } 161 | return &resp.Data, nil 162 | } 163 | 164 | // Archive archives a payment. 165 | func (s *PaymentsService) Archive(ctx context.Context, id string) (*Payment, error) { 166 | return s.bulkAction(ctx, "archive", id) 167 | } 168 | 169 | // Restore restores an archived payment. 170 | func (s *PaymentsService) Restore(ctx context.Context, id string) (*Payment, error) { 171 | return s.bulkAction(ctx, "restore", id) 172 | } 173 | 174 | // Bulk performs a bulk action on multiple payments. 175 | func (s *PaymentsService) Bulk(ctx context.Context, action string, ids []string) ([]Payment, error) { 176 | req := BulkAction{ 177 | Action: action, 178 | IDs: ids, 179 | } 180 | 181 | var resp ListResponse[Payment] 182 | if err := s.client.doRequest(ctx, "POST", "/api/v1/payments/bulk", nil, req, &resp); err != nil { 183 | return nil, err 184 | } 185 | return resp.Data, nil 186 | } 187 | 188 | // bulkAction performs a single-item bulk action. 189 | func (s *PaymentsService) bulkAction(ctx context.Context, action, id string) (*Payment, error) { 190 | payments, err := s.Bulk(ctx, action, []string{id}) 191 | if err != nil { 192 | return nil, err 193 | } 194 | if len(payments) == 0 { 195 | return nil, fmt.Errorf("no payment returned from bulk action") 196 | } 197 | return &payments[0], nil 198 | } 199 | 200 | // GetBlank retrieves a blank payment object with default values. 201 | func (s *PaymentsService) GetBlank(ctx context.Context) (*Payment, error) { 202 | var resp SingleResponse[Payment] 203 | if err := s.client.doRequest(ctx, "GET", "/api/v1/payments/create", nil, nil, &resp); err != nil { 204 | return nil, err 205 | } 206 | return &resp.Data, nil 207 | } 208 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | // Package invoiceninja provides a Go SDK for the Invoice Ninja API. 2 | // 3 | // This SDK supports both cloud-hosted (invoicing.co) and self-hosted Invoice Ninja instances. 4 | // It focuses on payment-related functionality while providing a generic request method 5 | // for accessing other API endpoints. 6 | // 7 | // # Authentication 8 | // 9 | // All requests require an API token obtained from Settings > Account Management > Integrations > API tokens. 10 | // 11 | // # Usage 12 | // 13 | // client := invoiceninja.NewClient("your-api-token") 14 | // // For self-hosted instances: 15 | // client.SetBaseURL("https://your-instance.com") 16 | // 17 | // // List payments 18 | // payments, err := client.Payments.List(ctx, nil) 19 | // 20 | // # Generic Requests 21 | // 22 | // For endpoints not covered by specialized methods, use the generic request: 23 | // 24 | // var result json.RawMessage 25 | // err := client.Request(ctx, "GET", "/api/v1/activities", nil, &result) 26 | package invoiceninja 27 | 28 | import ( 29 | "bytes" 30 | "context" 31 | "encoding/json" 32 | "fmt" 33 | "io" 34 | "net/http" 35 | "net/url" 36 | "strings" 37 | "time" 38 | ) 39 | 40 | const ( 41 | // DefaultBaseURL is the production Invoice Ninja cloud API endpoint. 42 | DefaultBaseURL = "https://invoicing.co" 43 | 44 | // DemoBaseURL is the demo Invoice Ninja API endpoint. 45 | DemoBaseURL = "https://demo.invoiceninja.com" 46 | 47 | // DefaultTimeout is the default HTTP client timeout. 48 | DefaultTimeout = 30 * time.Second 49 | 50 | // Version is the SDK version. 51 | Version = "1.0.0" 52 | ) 53 | 54 | // Client is the Invoice Ninja API client. 55 | type Client struct { 56 | // httpClient is the underlying HTTP client used for requests. 57 | httpClient *http.Client 58 | 59 | // baseURL is the API base URL. 60 | baseURL string 61 | 62 | // apiToken is the API authentication token. 63 | apiToken string 64 | 65 | // Payments provides access to payment-related endpoints. 66 | Payments *PaymentsService 67 | 68 | // Invoices provides access to invoice-related endpoints. 69 | Invoices *InvoicesService 70 | 71 | // Clients provides access to client-related endpoints. 72 | Clients *ClientsService 73 | 74 | // PaymentTerms provides access to payment terms endpoints. 75 | PaymentTerms *PaymentTermsService 76 | 77 | // Credits provides access to credit-related endpoints. 78 | Credits *CreditsService 79 | 80 | // Downloads provides access to file download operations. 81 | Downloads *DownloadsService 82 | 83 | // Uploads provides access to file upload operations. 84 | Uploads *UploadsService 85 | } 86 | 87 | // ClientOption is a function that configures a Client. 88 | type ClientOption func(*Client) 89 | 90 | // WithHTTPClient sets a custom HTTP client. 91 | func WithHTTPClient(httpClient *http.Client) ClientOption { 92 | return func(c *Client) { 93 | c.httpClient = httpClient 94 | } 95 | } 96 | 97 | // WithBaseURL sets a custom base URL (for self-hosted instances). 98 | func WithBaseURL(baseURL string) ClientOption { 99 | return func(c *Client) { 100 | c.baseURL = strings.TrimSuffix(baseURL, "/") 101 | } 102 | } 103 | 104 | // WithTimeout sets a custom timeout for the HTTP client. 105 | func WithTimeout(timeout time.Duration) ClientOption { 106 | return func(c *Client) { 107 | c.httpClient.Timeout = timeout 108 | } 109 | } 110 | 111 | // NewClient creates a new Invoice Ninja API client. 112 | func NewClient(apiToken string, opts ...ClientOption) *Client { 113 | c := &Client{ 114 | httpClient: &http.Client{ 115 | Timeout: DefaultTimeout, 116 | }, 117 | baseURL: DefaultBaseURL, 118 | apiToken: apiToken, 119 | } 120 | 121 | for _, opt := range opts { 122 | opt(c) 123 | } 124 | 125 | // Initialize services 126 | c.Payments = &PaymentsService{client: c} 127 | c.Invoices = &InvoicesService{client: c} 128 | c.Clients = &ClientsService{client: c} 129 | c.PaymentTerms = &PaymentTermsService{client: c} 130 | c.Credits = &CreditsService{client: c} 131 | c.Downloads = &DownloadsService{client: c} 132 | c.Uploads = &UploadsService{client: c} 133 | 134 | return c 135 | } 136 | 137 | // SetBaseURL sets the API base URL. Use this for self-hosted instances. 138 | func (c *Client) SetBaseURL(baseURL string) { 139 | c.baseURL = strings.TrimSuffix(baseURL, "/") 140 | } 141 | 142 | // Request performs a generic API request. 143 | // This method can be used to access any API endpoint not covered by specialized methods. 144 | func (c *Client) Request(ctx context.Context, method, path string, body interface{}, result interface{}) error { 145 | return c.doRequest(ctx, method, path, nil, body, result) 146 | } 147 | 148 | // RequestWithQuery performs a generic API request with query parameters. 149 | func (c *Client) RequestWithQuery(ctx context.Context, method, path string, query url.Values, body interface{}, result interface{}) error { 150 | return c.doRequest(ctx, method, path, query, body, result) 151 | } 152 | 153 | // doRequest performs the actual HTTP request. 154 | func (c *Client) doRequest(ctx context.Context, method, path string, query url.Values, body, result interface{}) error { 155 | // Build URL 156 | u, err := url.Parse(c.baseURL + path) 157 | if err != nil { 158 | return fmt.Errorf("invalid URL: %w", err) 159 | } 160 | if query != nil { 161 | u.RawQuery = query.Encode() 162 | } 163 | 164 | // Prepare request body 165 | var bodyReader io.Reader 166 | if body != nil { 167 | jsonBody, marshalErr := json.Marshal(body) 168 | if marshalErr != nil { 169 | return fmt.Errorf("failed to marshal request body: %w", marshalErr) 170 | } 171 | bodyReader = bytes.NewReader(jsonBody) 172 | } 173 | 174 | // Create request 175 | req, err := http.NewRequestWithContext(ctx, method, u.String(), bodyReader) 176 | if err != nil { 177 | return fmt.Errorf("failed to create request: %w", err) 178 | } 179 | 180 | // Set headers 181 | req.Header.Set("X-API-TOKEN", c.apiToken) 182 | req.Header.Set("X-Requested-With", "XMLHttpRequest") 183 | req.Header.Set("Content-Type", "application/json") 184 | req.Header.Set("Accept", "application/json") 185 | req.Header.Set("User-Agent", "go-invoice-ninja/"+Version) 186 | 187 | // Execute request 188 | resp, err := c.httpClient.Do(req) 189 | if err != nil { 190 | return fmt.Errorf("request failed: %w", err) 191 | } 192 | defer resp.Body.Close() 193 | 194 | // Read response body 195 | respBody, err := io.ReadAll(resp.Body) 196 | if err != nil { 197 | return fmt.Errorf("failed to read response body: %w", err) 198 | } 199 | 200 | // Check for errors 201 | if resp.StatusCode >= 400 { 202 | return parseAPIError(resp.StatusCode, respBody) 203 | } 204 | 205 | // Parse response 206 | if result != nil && len(respBody) > 0 { 207 | if err := json.Unmarshal(respBody, result); err != nil { 208 | return fmt.Errorf("failed to unmarshal response: %w", err) 209 | } 210 | } 211 | 212 | return nil 213 | } 214 | -------------------------------------------------------------------------------- /clients.go: -------------------------------------------------------------------------------- 1 | package invoiceninja 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "strconv" 8 | ) 9 | 10 | // ClientsService handles client-related API operations. 11 | type ClientsService struct { 12 | client *Client 13 | } 14 | 15 | // ClientListOptions specifies the optional parameters for listing clients. 16 | type ClientListOptions struct { 17 | // PerPage is the number of results per page (default 20). 18 | PerPage int 19 | 20 | // Page is the page number. 21 | Page int 22 | 23 | // Filter searches across multiple fields. 24 | Filter string 25 | 26 | // Balance filters by balance (e.g., "gt:1000", "lt:500"). 27 | Balance string 28 | 29 | // Status filters by status (comma-separated: active, archived, deleted). 30 | Status string 31 | 32 | // CreatedAt filters by creation date. 33 | CreatedAt string 34 | 35 | // UpdatedAt filters by update date. 36 | UpdatedAt string 37 | 38 | // IsDeleted filters by deleted status. 39 | IsDeleted *bool 40 | 41 | // Sort specifies the sort order (e.g., "name|desc", "balance|asc"). 42 | Sort string 43 | 44 | // Include specifies related entities to include (contacts, documents, activities). 45 | Include string 46 | } 47 | 48 | // toQuery converts options to URL query parameters. 49 | func (o *ClientListOptions) toQuery() url.Values { 50 | if o == nil { 51 | return nil 52 | } 53 | 54 | q := url.Values{} 55 | 56 | if o.PerPage > 0 { 57 | q.Set("per_page", strconv.Itoa(o.PerPage)) 58 | } 59 | if o.Page > 0 { 60 | q.Set("page", strconv.Itoa(o.Page)) 61 | } 62 | if o.Filter != "" { 63 | q.Set("filter", o.Filter) 64 | } 65 | if o.Balance != "" { 66 | q.Set("balance", o.Balance) 67 | } 68 | if o.Status != "" { 69 | q.Set("status", o.Status) 70 | } 71 | if o.CreatedAt != "" { 72 | q.Set("created_at", o.CreatedAt) 73 | } 74 | if o.UpdatedAt != "" { 75 | q.Set("updated_at", o.UpdatedAt) 76 | } 77 | if o.IsDeleted != nil { 78 | q.Set("is_deleted", strconv.FormatBool(*o.IsDeleted)) 79 | } 80 | if o.Sort != "" { 81 | q.Set("sort", o.Sort) 82 | } 83 | if o.Include != "" { 84 | q.Set("include", o.Include) 85 | } 86 | 87 | return q 88 | } 89 | 90 | // List retrieves a list of clients. 91 | func (s *ClientsService) List(ctx context.Context, opts *ClientListOptions) (*ListResponse[INClient], error) { 92 | var resp ListResponse[INClient] 93 | if err := s.client.doRequest(ctx, "GET", "/api/v1/clients", opts.toQuery(), nil, &resp); err != nil { 94 | return nil, err 95 | } 96 | return &resp, nil 97 | } 98 | 99 | // Get retrieves a single client by ID. 100 | func (s *ClientsService) Get(ctx context.Context, id string) (*INClient, error) { 101 | var resp SingleResponse[INClient] 102 | if err := s.client.doRequest(ctx, "GET", fmt.Sprintf("/api/v1/clients/%s", id), nil, nil, &resp); err != nil { 103 | return nil, err 104 | } 105 | return &resp.Data, nil 106 | } 107 | 108 | // Create creates a new client. 109 | func (s *ClientsService) Create(ctx context.Context, client *INClient) (*INClient, error) { 110 | var resp SingleResponse[INClient] 111 | if err := s.client.doRequest(ctx, "POST", "/api/v1/clients", nil, client, &resp); err != nil { 112 | return nil, err 113 | } 114 | return &resp.Data, nil 115 | } 116 | 117 | // Update updates an existing client. 118 | func (s *ClientsService) Update(ctx context.Context, id string, client *INClient) (*INClient, error) { 119 | var resp SingleResponse[INClient] 120 | if err := s.client.doRequest(ctx, "PUT", fmt.Sprintf("/api/v1/clients/%s", id), nil, client, &resp); err != nil { 121 | return nil, err 122 | } 123 | return &resp.Data, nil 124 | } 125 | 126 | // Delete deletes a client by ID (soft delete). 127 | func (s *ClientsService) Delete(ctx context.Context, id string) error { 128 | return s.client.doRequest(ctx, "DELETE", fmt.Sprintf("/api/v1/clients/%s", id), nil, nil, nil) 129 | } 130 | 131 | // Purge permanently removes a client and all their records. 132 | func (s *ClientsService) Purge(ctx context.Context, id string) error { 133 | return s.client.doRequest(ctx, "POST", fmt.Sprintf("/api/v1/clients/%s/purge", id), nil, nil, nil) 134 | } 135 | 136 | // Archive archives a client. 137 | func (s *ClientsService) Archive(ctx context.Context, id string) (*INClient, error) { 138 | return s.bulkAction(ctx, "archive", id) 139 | } 140 | 141 | // Restore restores an archived client. 142 | func (s *ClientsService) Restore(ctx context.Context, id string) (*INClient, error) { 143 | return s.bulkAction(ctx, "restore", id) 144 | } 145 | 146 | // Merge merges two clients. 147 | func (s *ClientsService) Merge(ctx context.Context, primaryID, mergeableID string) (*INClient, error) { 148 | var resp SingleResponse[INClient] 149 | path := fmt.Sprintf("/api/v1/clients/%s/%s/merge", primaryID, mergeableID) 150 | if err := s.client.doRequest(ctx, "POST", path, nil, nil, &resp); err != nil { 151 | return nil, err 152 | } 153 | return &resp.Data, nil 154 | } 155 | 156 | // Bulk performs a bulk action on multiple clients. 157 | func (s *ClientsService) Bulk(ctx context.Context, action string, ids []string) ([]INClient, error) { 158 | req := BulkAction{ 159 | Action: action, 160 | IDs: ids, 161 | } 162 | 163 | var resp ListResponse[INClient] 164 | if err := s.client.doRequest(ctx, "POST", "/api/v1/clients/bulk", nil, req, &resp); err != nil { 165 | return nil, err 166 | } 167 | return resp.Data, nil 168 | } 169 | 170 | // bulkAction performs a single-item bulk action. 171 | func (s *ClientsService) bulkAction(ctx context.Context, action, id string) (*INClient, error) { 172 | clients, err := s.Bulk(ctx, action, []string{id}) 173 | if err != nil { 174 | return nil, err 175 | } 176 | if len(clients) == 0 { 177 | return nil, fmt.Errorf("no client returned from bulk action") 178 | } 179 | return &clients[0], nil 180 | } 181 | 182 | // GetBlank retrieves a blank client object with default values. 183 | func (s *ClientsService) GetBlank(ctx context.Context) (*INClient, error) { 184 | var resp SingleResponse[INClient] 185 | if err := s.client.doRequest(ctx, "GET", "/api/v1/clients/create", nil, nil, &resp); err != nil { 186 | return nil, err 187 | } 188 | return &resp.Data, nil 189 | } 190 | 191 | // StatementRequest represents a client statement request. 192 | type StatementRequest struct { 193 | ClientID string `json:"client_id"` 194 | StartDate string `json:"start_date,omitempty"` 195 | EndDate string `json:"end_date,omitempty"` 196 | ShowPayments bool `json:"show_payments_table,omitempty"` 197 | ShowAging bool `json:"show_aging_table,omitempty"` 198 | ShowCredits bool `json:"show_credits_table,omitempty"` 199 | Status string `json:"status,omitempty"` 200 | } 201 | 202 | // GetStatement generates a client statement. 203 | func (s *ClientsService) GetStatement(ctx context.Context, req *StatementRequest) ([]byte, error) { 204 | // This would need special handling for PDF response 205 | return nil, fmt.Errorf("not implemented - use client.Request with custom handling") 206 | } 207 | -------------------------------------------------------------------------------- /webhooks.go: -------------------------------------------------------------------------------- 1 | package invoiceninja 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "strings" 12 | ) 13 | 14 | // WebhookEvent represents an Invoice Ninja webhook event. 15 | type WebhookEvent struct { 16 | // EventType is the type of event (e.g., "invoice.created", "payment.created"). 17 | EventType string `json:"event_type"` 18 | 19 | // Data contains the event payload. 20 | Data json.RawMessage `json:"data"` 21 | } 22 | 23 | // WebhookHandler handles incoming webhook requests from Invoice Ninja. 24 | type WebhookHandler struct { 25 | // secret is the webhook signing secret for signature verification. 26 | secret string 27 | 28 | // handlers maps event types to handler functions. 29 | handlers map[string]WebhookEventHandler 30 | } 31 | 32 | // WebhookEventHandler is a function that handles a specific webhook event. 33 | type WebhookEventHandler func(event *WebhookEvent) error 34 | 35 | // NewWebhookHandler creates a new webhook handler. 36 | // If secret is provided, signature verification will be enforced. 37 | func NewWebhookHandler(secret string) *WebhookHandler { 38 | return &WebhookHandler{ 39 | secret: secret, 40 | handlers: make(map[string]WebhookEventHandler), 41 | } 42 | } 43 | 44 | // On registers a handler for a specific event type. 45 | func (h *WebhookHandler) On(eventType string, handler WebhookEventHandler) { 46 | h.handlers[eventType] = handler 47 | } 48 | 49 | // OnInvoiceCreated registers a handler for invoice.created events. 50 | func (h *WebhookHandler) OnInvoiceCreated(handler WebhookEventHandler) { 51 | h.On("invoice.created", handler) 52 | } 53 | 54 | // OnInvoiceUpdated registers a handler for invoice.updated events. 55 | func (h *WebhookHandler) OnInvoiceUpdated(handler WebhookEventHandler) { 56 | h.On("invoice.updated", handler) 57 | } 58 | 59 | // OnInvoiceDeleted registers a handler for invoice.deleted events. 60 | func (h *WebhookHandler) OnInvoiceDeleted(handler WebhookEventHandler) { 61 | h.On("invoice.deleted", handler) 62 | } 63 | 64 | // OnPaymentCreated registers a handler for payment.created events. 65 | func (h *WebhookHandler) OnPaymentCreated(handler WebhookEventHandler) { 66 | h.On("payment.created", handler) 67 | } 68 | 69 | // OnPaymentUpdated registers a handler for payment.updated events. 70 | func (h *WebhookHandler) OnPaymentUpdated(handler WebhookEventHandler) { 71 | h.On("payment.updated", handler) 72 | } 73 | 74 | // OnPaymentDeleted registers a handler for payment.deleted events. 75 | func (h *WebhookHandler) OnPaymentDeleted(handler WebhookEventHandler) { 76 | h.On("payment.deleted", handler) 77 | } 78 | 79 | // OnClientCreated registers a handler for client.created events. 80 | func (h *WebhookHandler) OnClientCreated(handler WebhookEventHandler) { 81 | h.On("client.created", handler) 82 | } 83 | 84 | // OnClientUpdated registers a handler for client.updated events. 85 | func (h *WebhookHandler) OnClientUpdated(handler WebhookEventHandler) { 86 | h.On("client.updated", handler) 87 | } 88 | 89 | // OnCreditCreated registers a handler for credit.created events. 90 | func (h *WebhookHandler) OnCreditCreated(handler WebhookEventHandler) { 91 | h.On("credit.created", handler) 92 | } 93 | 94 | // OnQuoteCreated registers a handler for quote.created events. 95 | func (h *WebhookHandler) OnQuoteCreated(handler WebhookEventHandler) { 96 | h.On("quote.created", handler) 97 | } 98 | 99 | // HandleRequest processes an incoming webhook HTTP request. 100 | func (h *WebhookHandler) HandleRequest(w http.ResponseWriter, r *http.Request) { 101 | if r.Method != http.MethodPost { 102 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 103 | return 104 | } 105 | 106 | body, err := io.ReadAll(r.Body) 107 | if err != nil { 108 | http.Error(w, "Failed to read request body", http.StatusBadRequest) 109 | return 110 | } 111 | defer r.Body.Close() 112 | 113 | // Verify signature if secret is configured 114 | if h.secret != "" { 115 | signature := r.Header.Get("X-Ninja-Signature") 116 | if signature == "" { 117 | signature = r.Header.Get("X-Invoice-Ninja-Signature") 118 | } 119 | 120 | if !h.verifySignature(body, signature) { 121 | http.Error(w, "Invalid signature", http.StatusUnauthorized) 122 | return 123 | } 124 | } 125 | 126 | var event WebhookEvent 127 | if err := json.Unmarshal(body, &event); err != nil { 128 | http.Error(w, "Failed to parse webhook payload", http.StatusBadRequest) 129 | return 130 | } 131 | 132 | // Find and execute the handler 133 | handler, ok := h.handlers[event.EventType] 134 | if !ok { 135 | // No handler registered for this event type, acknowledge receipt 136 | w.WriteHeader(http.StatusOK) 137 | return 138 | } 139 | 140 | if err := handler(&event); err != nil { 141 | http.Error(w, fmt.Sprintf("Handler error: %v", err), http.StatusInternalServerError) 142 | return 143 | } 144 | 145 | w.WriteHeader(http.StatusOK) 146 | } 147 | 148 | // verifySignature verifies the webhook signature. 149 | func (h *WebhookHandler) verifySignature(payload []byte, signature string) bool { 150 | if signature == "" { 151 | return false 152 | } 153 | 154 | // Remove "sha256=" prefix if present 155 | signature = strings.TrimPrefix(signature, "sha256=") 156 | 157 | mac := hmac.New(sha256.New, []byte(h.secret)) 158 | mac.Write(payload) 159 | expectedMAC := hex.EncodeToString(mac.Sum(nil)) 160 | 161 | return hmac.Equal([]byte(signature), []byte(expectedMAC)) 162 | } 163 | 164 | // ParseInvoice parses the webhook data as an Invoice. 165 | func (e *WebhookEvent) ParseInvoice() (*Invoice, error) { 166 | var invoice Invoice 167 | if err := json.Unmarshal(e.Data, &invoice); err != nil { 168 | return nil, fmt.Errorf("failed to parse invoice data: %w", err) 169 | } 170 | return &invoice, nil 171 | } 172 | 173 | // ParsePayment parses the webhook data as a Payment. 174 | func (e *WebhookEvent) ParsePayment() (*Payment, error) { 175 | var payment Payment 176 | if err := json.Unmarshal(e.Data, &payment); err != nil { 177 | return nil, fmt.Errorf("failed to parse payment data: %w", err) 178 | } 179 | return &payment, nil 180 | } 181 | 182 | // ParseClient parses the webhook data as a Client. 183 | func (e *WebhookEvent) ParseClient() (*INClient, error) { 184 | var client INClient 185 | if err := json.Unmarshal(e.Data, &client); err != nil { 186 | return nil, fmt.Errorf("failed to parse client data: %w", err) 187 | } 188 | return &client, nil 189 | } 190 | 191 | // ParseCredit parses the webhook data as a Credit. 192 | func (e *WebhookEvent) ParseCredit() (*Credit, error) { 193 | var credit Credit 194 | if err := json.Unmarshal(e.Data, &credit); err != nil { 195 | return nil, fmt.Errorf("failed to parse credit data: %w", err) 196 | } 197 | return &credit, nil 198 | } 199 | 200 | // ServeHTTP implements http.Handler interface. 201 | func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 202 | h.HandleRequest(w, r) 203 | } 204 | -------------------------------------------------------------------------------- /webhooks_test.go: -------------------------------------------------------------------------------- 1 | package invoiceninja 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | ) 10 | 11 | func TestWebhookHandler(t *testing.T) { 12 | handler := NewWebhookHandler("") 13 | 14 | var receivedEvent *WebhookEvent 15 | handler.OnPaymentCreated(func(event *WebhookEvent) error { 16 | receivedEvent = event 17 | return nil 18 | }) 19 | 20 | payload := map[string]interface{}{ 21 | "event_type": "payment.created", 22 | "data": map[string]interface{}{ 23 | "id": "pay123", 24 | "amount": 100.00, 25 | }, 26 | } 27 | body, _ := json.Marshal(payload) 28 | 29 | req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) 30 | req.Header.Set("Content-Type", "application/json") 31 | 32 | w := httptest.NewRecorder() 33 | handler.HandleRequest(w, req) 34 | 35 | if w.Code != http.StatusOK { 36 | t.Errorf("expected status 200, got %d", w.Code) 37 | } 38 | 39 | if receivedEvent == nil { 40 | t.Fatal("expected event to be received") 41 | } 42 | 43 | if receivedEvent.EventType != "payment.created" { 44 | t.Errorf("expected event type 'payment.created', got '%s'", receivedEvent.EventType) 45 | } 46 | } 47 | 48 | func TestWebhookHandlerWithSignature(t *testing.T) { 49 | secret := "test-secret" 50 | handler := NewWebhookHandler(secret) 51 | 52 | handler.OnInvoiceCreated(func(event *WebhookEvent) error { 53 | return nil 54 | }) 55 | 56 | payload := []byte(`{"event_type":"invoice.created","data":{"id":"inv123"}}`) 57 | 58 | // Test with invalid signature 59 | req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload)) 60 | req.Header.Set("Content-Type", "application/json") 61 | req.Header.Set("X-Ninja-Signature", "invalid-signature") 62 | 63 | w := httptest.NewRecorder() 64 | handler.HandleRequest(w, req) 65 | 66 | if w.Code != http.StatusUnauthorized { 67 | t.Errorf("expected status 401 for invalid signature, got %d", w.Code) 68 | } 69 | } 70 | 71 | func TestWebhookHandlerMethodNotAllowed(t *testing.T) { 72 | handler := NewWebhookHandler("") 73 | 74 | req := httptest.NewRequest(http.MethodGet, "/webhook", nil) 75 | 76 | w := httptest.NewRecorder() 77 | handler.HandleRequest(w, req) 78 | 79 | if w.Code != http.StatusMethodNotAllowed { 80 | t.Errorf("expected status 405, got %d", w.Code) 81 | } 82 | } 83 | 84 | func TestWebhookHandlerUnregisteredEvent(t *testing.T) { 85 | handler := NewWebhookHandler("") 86 | 87 | payload := []byte(`{"event_type":"unknown.event","data":{}}`) 88 | 89 | req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload)) 90 | req.Header.Set("Content-Type", "application/json") 91 | 92 | w := httptest.NewRecorder() 93 | handler.HandleRequest(w, req) 94 | 95 | // Should still return 200 for unregistered events 96 | if w.Code != http.StatusOK { 97 | t.Errorf("expected status 200 for unregistered event, got %d", w.Code) 98 | } 99 | } 100 | 101 | func TestWebhookEventParsers(t *testing.T) { 102 | // Test ParseInvoice 103 | invoiceEvent := &WebhookEvent{ 104 | EventType: "invoice.created", 105 | Data: json.RawMessage(`{"id":"inv123","number":"INV001","amount":500.00}`), 106 | } 107 | 108 | invoice, err := invoiceEvent.ParseInvoice() 109 | if err != nil { 110 | t.Fatalf("failed to parse invoice: %v", err) 111 | } 112 | if invoice.ID != "inv123" { 113 | t.Errorf("expected invoice ID 'inv123', got '%s'", invoice.ID) 114 | } 115 | if invoice.Amount != 500.00 { 116 | t.Errorf("expected amount 500.00, got %f", invoice.Amount) 117 | } 118 | 119 | // Test ParsePayment 120 | paymentEvent := &WebhookEvent{ 121 | EventType: "payment.created", 122 | Data: json.RawMessage(`{"id":"pay123","amount":100.00,"client_id":"client123"}`), 123 | } 124 | 125 | payment, err := paymentEvent.ParsePayment() 126 | if err != nil { 127 | t.Fatalf("failed to parse payment: %v", err) 128 | } 129 | if payment.ID != "pay123" { 130 | t.Errorf("expected payment ID 'pay123', got '%s'", payment.ID) 131 | } 132 | 133 | // Test ParseClient 134 | clientEvent := &WebhookEvent{ 135 | EventType: "client.created", 136 | Data: json.RawMessage(`{"id":"client123","name":"Acme Corp"}`), 137 | } 138 | 139 | client, err := clientEvent.ParseClient() 140 | if err != nil { 141 | t.Fatalf("failed to parse client: %v", err) 142 | } 143 | if client.ID != "client123" { 144 | t.Errorf("expected client ID 'client123', got '%s'", client.ID) 145 | } 146 | if client.Name != "Acme Corp" { 147 | t.Errorf("expected client name 'Acme Corp', got '%s'", client.Name) 148 | } 149 | 150 | // Test ParseCredit 151 | creditEvent := &WebhookEvent{ 152 | EventType: "credit.created", 153 | Data: json.RawMessage(`{"id":"credit123","number":"CR001","amount":50.00}`), 154 | } 155 | 156 | credit, err := creditEvent.ParseCredit() 157 | if err != nil { 158 | t.Fatalf("failed to parse credit: %v", err) 159 | } 160 | if credit.ID != "credit123" { 161 | t.Errorf("expected credit ID 'credit123', got '%s'", credit.ID) 162 | } 163 | } 164 | 165 | func TestWebhookHandlerServeHTTP(t *testing.T) { 166 | handler := NewWebhookHandler("") 167 | 168 | called := false 169 | handler.OnPaymentCreated(func(event *WebhookEvent) error { 170 | called = true 171 | return nil 172 | }) 173 | 174 | payload := []byte(`{"event_type":"payment.created","data":{"id":"pay123"}}`) 175 | 176 | req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload)) 177 | req.Header.Set("Content-Type", "application/json") 178 | 179 | w := httptest.NewRecorder() 180 | handler.ServeHTTP(w, req) 181 | 182 | if !called { 183 | t.Error("expected handler to be called") 184 | } 185 | 186 | if w.Code != http.StatusOK { 187 | t.Errorf("expected status 200, got %d", w.Code) 188 | } 189 | } 190 | 191 | func TestWebhookHandlerRegistrations(t *testing.T) { 192 | handler := NewWebhookHandler("") 193 | 194 | events := []string{ 195 | "invoice.created", 196 | "invoice.updated", 197 | "invoice.deleted", 198 | "payment.created", 199 | "payment.updated", 200 | "payment.deleted", 201 | "client.created", 202 | "client.updated", 203 | "credit.created", 204 | "quote.created", 205 | } 206 | 207 | // Register handlers for all event types 208 | handler.OnInvoiceCreated(func(e *WebhookEvent) error { return nil }) 209 | handler.OnInvoiceUpdated(func(e *WebhookEvent) error { return nil }) 210 | handler.OnInvoiceDeleted(func(e *WebhookEvent) error { return nil }) 211 | handler.OnPaymentCreated(func(e *WebhookEvent) error { return nil }) 212 | handler.OnPaymentUpdated(func(e *WebhookEvent) error { return nil }) 213 | handler.OnPaymentDeleted(func(e *WebhookEvent) error { return nil }) 214 | handler.OnClientCreated(func(e *WebhookEvent) error { return nil }) 215 | handler.OnClientUpdated(func(e *WebhookEvent) error { return nil }) 216 | handler.OnCreditCreated(func(e *WebhookEvent) error { return nil }) 217 | handler.OnQuoteCreated(func(e *WebhookEvent) error { return nil }) 218 | 219 | // Verify all handlers are registered 220 | for _, eventType := range events { 221 | if _, ok := handler.handlers[eventType]; !ok { 222 | t.Errorf("expected handler for event type '%s' to be registered", eventType) 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /credits_test.go: -------------------------------------------------------------------------------- 1 | package invoiceninja 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | ) 10 | 11 | func TestCreditsServiceList(t *testing.T) { 12 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 13 | if r.Method != "GET" { 14 | t.Errorf("expected GET method, got %s", r.Method) 15 | } 16 | if r.URL.Path != "/api/v1/credits" { 17 | t.Errorf("expected path /api/v1/credits, got %s", r.URL.Path) 18 | } 19 | 20 | w.Header().Set("Content-Type", "application/json") 21 | json.NewEncoder(w).Encode(map[string]interface{}{ 22 | "data": []map[string]interface{}{ 23 | {"id": "credit1", "number": "CR001", "amount": 100.00}, 24 | {"id": "credit2", "number": "CR002", "amount": 200.00}, 25 | }, 26 | }) 27 | })) 28 | defer server.Close() 29 | 30 | client := NewClient("test-token", WithBaseURL(server.URL)) 31 | 32 | resp, err := client.Credits.List(context.Background(), nil) 33 | if err != nil { 34 | t.Fatalf("unexpected error: %v", err) 35 | } 36 | 37 | if len(resp.Data) != 2 { 38 | t.Errorf("expected 2 credits, got %d", len(resp.Data)) 39 | } 40 | 41 | if resp.Data[0].Number != "CR001" { 42 | t.Errorf("expected first credit number to be 'CR001', got '%s'", resp.Data[0].Number) 43 | } 44 | } 45 | 46 | func TestCreditsServiceGet(t *testing.T) { 47 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 48 | if r.URL.Path != "/api/v1/credits/credit1" { 49 | t.Errorf("expected path /api/v1/credits/credit1, got %s", r.URL.Path) 50 | } 51 | 52 | w.Header().Set("Content-Type", "application/json") 53 | json.NewEncoder(w).Encode(map[string]interface{}{ 54 | "data": map[string]interface{}{ 55 | "id": "credit1", 56 | "number": "CR001", 57 | "amount": 100.00, 58 | "balance": 50.00, 59 | "client_id": "client123", 60 | }, 61 | }) 62 | })) 63 | defer server.Close() 64 | 65 | client := NewClient("test-token", WithBaseURL(server.URL)) 66 | 67 | credit, err := client.Credits.Get(context.Background(), "credit1") 68 | if err != nil { 69 | t.Fatalf("unexpected error: %v", err) 70 | } 71 | 72 | if credit.ID != "credit1" { 73 | t.Errorf("expected credit ID to be 'credit1', got '%s'", credit.ID) 74 | } 75 | 76 | if credit.Balance != 50.00 { 77 | t.Errorf("expected balance to be 50.00, got %f", credit.Balance) 78 | } 79 | } 80 | 81 | func TestCreditsServiceCreate(t *testing.T) { 82 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 83 | if r.Method != "POST" { 84 | t.Errorf("expected POST method, got %s", r.Method) 85 | } 86 | 87 | var body Credit 88 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 89 | t.Errorf("failed to decode request body: %v", err) 90 | } 91 | 92 | if body.ClientID != "client123" { 93 | t.Errorf("expected client_id to be 'client123', got '%s'", body.ClientID) 94 | } 95 | 96 | w.Header().Set("Content-Type", "application/json") 97 | json.NewEncoder(w).Encode(map[string]interface{}{ 98 | "data": map[string]interface{}{ 99 | "id": "newcredit", 100 | "client_id": "client123", 101 | "number": "CR003", 102 | "amount": 150.00, 103 | }, 104 | }) 105 | })) 106 | defer server.Close() 107 | 108 | client := NewClient("test-token", WithBaseURL(server.URL)) 109 | 110 | credit, err := client.Credits.Create(context.Background(), &Credit{ 111 | ClientID: "client123", 112 | LineItems: []LineItem{ 113 | {ProductKey: "Credit Item", Quantity: 1, Cost: 150.00}, 114 | }, 115 | }) 116 | if err != nil { 117 | t.Fatalf("unexpected error: %v", err) 118 | } 119 | 120 | if credit.ID != "newcredit" { 121 | t.Errorf("expected credit ID to be 'newcredit', got '%s'", credit.ID) 122 | } 123 | } 124 | 125 | func TestCreditsServiceUpdate(t *testing.T) { 126 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 127 | if r.Method != "PUT" { 128 | t.Errorf("expected PUT method, got %s", r.Method) 129 | } 130 | 131 | w.Header().Set("Content-Type", "application/json") 132 | json.NewEncoder(w).Encode(map[string]interface{}{ 133 | "data": map[string]interface{}{ 134 | "id": "credit1", 135 | "private_notes": "Updated notes", 136 | }, 137 | }) 138 | })) 139 | defer server.Close() 140 | 141 | client := NewClient("test-token", WithBaseURL(server.URL)) 142 | 143 | credit, err := client.Credits.Update(context.Background(), "credit1", &Credit{ 144 | PrivateNotes: "Updated notes", 145 | }) 146 | if err != nil { 147 | t.Fatalf("unexpected error: %v", err) 148 | } 149 | 150 | if credit.ID != "credit1" { 151 | t.Errorf("expected credit ID to be 'credit1', got '%s'", credit.ID) 152 | } 153 | } 154 | 155 | func TestCreditsServiceDelete(t *testing.T) { 156 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 157 | if r.Method != "DELETE" { 158 | t.Errorf("expected DELETE method, got %s", r.Method) 159 | } 160 | w.WriteHeader(http.StatusOK) 161 | })) 162 | defer server.Close() 163 | 164 | client := NewClient("test-token", WithBaseURL(server.URL)) 165 | 166 | err := client.Credits.Delete(context.Background(), "credit1") 167 | if err != nil { 168 | t.Fatalf("unexpected error: %v", err) 169 | } 170 | } 171 | 172 | func TestCreditsServiceBulk(t *testing.T) { 173 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 174 | if r.Method != "POST" { 175 | t.Errorf("expected POST method, got %s", r.Method) 176 | } 177 | if r.URL.Path != "/api/v1/credits/bulk" { 178 | t.Errorf("expected path /api/v1/credits/bulk, got %s", r.URL.Path) 179 | } 180 | 181 | var body BulkAction 182 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 183 | t.Errorf("failed to decode request body: %v", err) 184 | } 185 | 186 | if body.Action != "mark_sent" { 187 | t.Errorf("expected action to be 'mark_sent', got '%s'", body.Action) 188 | } 189 | 190 | w.Header().Set("Content-Type", "application/json") 191 | json.NewEncoder(w).Encode(map[string]interface{}{ 192 | "data": []map[string]interface{}{ 193 | {"id": "credit1"}, 194 | }, 195 | }) 196 | })) 197 | defer server.Close() 198 | 199 | client := NewClient("test-token", WithBaseURL(server.URL)) 200 | 201 | credits, err := client.Credits.Bulk(context.Background(), "mark_sent", []string{"credit1"}) 202 | if err != nil { 203 | t.Fatalf("unexpected error: %v", err) 204 | } 205 | 206 | if len(credits) != 1 { 207 | t.Errorf("expected 1 credit, got %d", len(credits)) 208 | } 209 | } 210 | 211 | func TestCreditListOptionsToQuery(t *testing.T) { 212 | isDeleted := false 213 | opts := &CreditListOptions{ 214 | PerPage: 25, 215 | Page: 3, 216 | Filter: "search term", 217 | ClientID: "client456", 218 | Status: "active", 219 | CreatedAt: "2024-02-01", 220 | UpdatedAt: "2024-02-15", 221 | IsDeleted: &isDeleted, 222 | Sort: "number|asc", 223 | Include: "client", 224 | } 225 | 226 | q := opts.toQuery() 227 | 228 | if q.Get("per_page") != "25" { 229 | t.Errorf("expected per_page=25, got %s", q.Get("per_page")) 230 | } 231 | if q.Get("client_id") != "client456" { 232 | t.Errorf("expected client_id=client456, got %s", q.Get("client_id")) 233 | } 234 | if q.Get("is_deleted") != "false" { 235 | t.Errorf("expected is_deleted=false, got %s", q.Get("is_deleted")) 236 | } 237 | } 238 | 239 | func TestCreditListOptionsNilToQuery(t *testing.T) { 240 | var opts *CreditListOptions = nil 241 | q := opts.toQuery() 242 | if q != nil { 243 | t.Error("expected nil query for nil options") 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /credits.go: -------------------------------------------------------------------------------- 1 | package invoiceninja 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "strconv" 8 | ) 9 | 10 | // CreditsService handles credit-related API operations. 11 | type CreditsService struct { 12 | client *Client 13 | } 14 | 15 | // Credit represents a credit note in Invoice Ninja. 16 | type Credit struct { 17 | ID string `json:"id,omitempty"` 18 | UserID string `json:"user_id,omitempty"` 19 | AssignedUserID string `json:"assigned_user_id,omitempty"` 20 | ClientID string `json:"client_id,omitempty"` 21 | StatusID string `json:"status_id,omitempty"` 22 | InvoiceID string `json:"invoice_id,omitempty"` 23 | Number string `json:"number,omitempty"` 24 | PONumber string `json:"po_number,omitempty"` 25 | Terms string `json:"terms,omitempty"` 26 | PublicNotes string `json:"public_notes,omitempty"` 27 | PrivateNotes string `json:"private_notes,omitempty"` 28 | Footer string `json:"footer,omitempty"` 29 | CustomValue1 string `json:"custom_value1,omitempty"` 30 | CustomValue2 string `json:"custom_value2,omitempty"` 31 | CustomValue3 string `json:"custom_value3,omitempty"` 32 | CustomValue4 string `json:"custom_value4,omitempty"` 33 | TaxName1 string `json:"tax_name1,omitempty"` 34 | TaxName2 string `json:"tax_name2,omitempty"` 35 | TaxName3 string `json:"tax_name3,omitempty"` 36 | TaxRate1 float64 `json:"tax_rate1,omitempty"` 37 | TaxRate2 float64 `json:"tax_rate2,omitempty"` 38 | TaxRate3 float64 `json:"tax_rate3,omitempty"` 39 | TotalTaxes float64 `json:"total_taxes,omitempty"` 40 | Amount float64 `json:"amount,omitempty"` 41 | Balance float64 `json:"balance,omitempty"` 42 | PaidToDate float64 `json:"paid_to_date,omitempty"` 43 | Discount float64 `json:"discount,omitempty"` 44 | Partial float64 `json:"partial,omitempty"` 45 | IsAmountDiscount bool `json:"is_amount_discount,omitempty"` 46 | IsDeleted bool `json:"is_deleted,omitempty"` 47 | UsesInclusiveTaxes bool `json:"uses_inclusive_taxes,omitempty"` 48 | Date string `json:"date,omitempty"` 49 | LastSentDate string `json:"last_sent_date,omitempty"` 50 | NextSendDate string `json:"next_send_date,omitempty"` 51 | PartialDueDate string `json:"partial_due_date,omitempty"` 52 | DueDate string `json:"due_date,omitempty"` 53 | LineItems []LineItem `json:"line_items,omitempty"` 54 | UpdatedAt int64 `json:"updated_at,omitempty"` 55 | ArchivedAt int64 `json:"archived_at,omitempty"` 56 | CreatedAt int64 `json:"created_at,omitempty"` 57 | } 58 | 59 | // CreditListOptions specifies the optional parameters for listing credits. 60 | type CreditListOptions struct { 61 | PerPage int 62 | Page int 63 | Filter string 64 | ClientID string 65 | Status string 66 | CreatedAt string 67 | UpdatedAt string 68 | IsDeleted *bool 69 | Sort string 70 | Include string 71 | } 72 | 73 | // toQuery converts options to URL query parameters. 74 | func (o *CreditListOptions) toQuery() url.Values { 75 | if o == nil { 76 | return nil 77 | } 78 | 79 | q := url.Values{} 80 | 81 | if o.PerPage > 0 { 82 | q.Set("per_page", strconv.Itoa(o.PerPage)) 83 | } 84 | if o.Page > 0 { 85 | q.Set("page", strconv.Itoa(o.Page)) 86 | } 87 | if o.Filter != "" { 88 | q.Set("filter", o.Filter) 89 | } 90 | if o.ClientID != "" { 91 | q.Set("client_id", o.ClientID) 92 | } 93 | if o.Status != "" { 94 | q.Set("status", o.Status) 95 | } 96 | if o.CreatedAt != "" { 97 | q.Set("created_at", o.CreatedAt) 98 | } 99 | if o.UpdatedAt != "" { 100 | q.Set("updated_at", o.UpdatedAt) 101 | } 102 | if o.IsDeleted != nil { 103 | q.Set("is_deleted", strconv.FormatBool(*o.IsDeleted)) 104 | } 105 | if o.Sort != "" { 106 | q.Set("sort", o.Sort) 107 | } 108 | if o.Include != "" { 109 | q.Set("include", o.Include) 110 | } 111 | 112 | return q 113 | } 114 | 115 | // List retrieves a list of credits. 116 | func (s *CreditsService) List(ctx context.Context, opts *CreditListOptions) (*ListResponse[Credit], error) { 117 | var resp ListResponse[Credit] 118 | if err := s.client.doRequest(ctx, "GET", "/api/v1/credits", opts.toQuery(), nil, &resp); err != nil { 119 | return nil, err 120 | } 121 | return &resp, nil 122 | } 123 | 124 | // Get retrieves a single credit by ID. 125 | func (s *CreditsService) Get(ctx context.Context, id string) (*Credit, error) { 126 | var resp SingleResponse[Credit] 127 | if err := s.client.doRequest(ctx, "GET", fmt.Sprintf("/api/v1/credits/%s", id), nil, nil, &resp); err != nil { 128 | return nil, err 129 | } 130 | return &resp.Data, nil 131 | } 132 | 133 | // Create creates a new credit. 134 | func (s *CreditsService) Create(ctx context.Context, credit *Credit) (*Credit, error) { 135 | var resp SingleResponse[Credit] 136 | if err := s.client.doRequest(ctx, "POST", "/api/v1/credits", nil, credit, &resp); err != nil { 137 | return nil, err 138 | } 139 | return &resp.Data, nil 140 | } 141 | 142 | // Update updates an existing credit. 143 | func (s *CreditsService) Update(ctx context.Context, id string, credit *Credit) (*Credit, error) { 144 | var resp SingleResponse[Credit] 145 | if err := s.client.doRequest(ctx, "PUT", fmt.Sprintf("/api/v1/credits/%s", id), nil, credit, &resp); err != nil { 146 | return nil, err 147 | } 148 | return &resp.Data, nil 149 | } 150 | 151 | // Delete deletes a credit by ID. 152 | func (s *CreditsService) Delete(ctx context.Context, id string) error { 153 | return s.client.doRequest(ctx, "DELETE", fmt.Sprintf("/api/v1/credits/%s", id), nil, nil, nil) 154 | } 155 | 156 | // Bulk performs a bulk action on multiple credits. 157 | func (s *CreditsService) Bulk(ctx context.Context, action string, ids []string) ([]Credit, error) { 158 | req := BulkAction{ 159 | Action: action, 160 | IDs: ids, 161 | } 162 | 163 | var resp ListResponse[Credit] 164 | if err := s.client.doRequest(ctx, "POST", "/api/v1/credits/bulk", nil, req, &resp); err != nil { 165 | return nil, err 166 | } 167 | return resp.Data, nil 168 | } 169 | 170 | // Archive archives a credit. 171 | func (s *CreditsService) Archive(ctx context.Context, id string) (*Credit, error) { 172 | return s.bulkAction(ctx, "archive", id) 173 | } 174 | 175 | // Restore restores an archived credit. 176 | func (s *CreditsService) Restore(ctx context.Context, id string) (*Credit, error) { 177 | return s.bulkAction(ctx, "restore", id) 178 | } 179 | 180 | // MarkSent marks a credit as sent. 181 | func (s *CreditsService) MarkSent(ctx context.Context, id string) (*Credit, error) { 182 | return s.bulkAction(ctx, "mark_sent", id) 183 | } 184 | 185 | // Email sends a credit via email. 186 | func (s *CreditsService) Email(ctx context.Context, id string) (*Credit, error) { 187 | return s.bulkAction(ctx, "email", id) 188 | } 189 | 190 | // bulkAction performs a single-item bulk action. 191 | func (s *CreditsService) bulkAction(ctx context.Context, action, id string) (*Credit, error) { 192 | credits, err := s.Bulk(ctx, action, []string{id}) 193 | if err != nil { 194 | return nil, err 195 | } 196 | if len(credits) == 0 { 197 | return nil, fmt.Errorf("no credit returned from bulk action") 198 | } 199 | return &credits[0], nil 200 | } 201 | 202 | // GetBlank retrieves a blank credit object with default values. 203 | func (s *CreditsService) GetBlank(ctx context.Context) (*Credit, error) { 204 | var resp SingleResponse[Credit] 205 | if err := s.client.doRequest(ctx, "GET", "/api/v1/credits/create", nil, nil, &resp); err != nil { 206 | return nil, err 207 | } 208 | return &resp.Data, nil 209 | } 210 | -------------------------------------------------------------------------------- /retry.go: -------------------------------------------------------------------------------- 1 | package invoiceninja 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "math" 7 | "math/big" 8 | "net/http" 9 | "strconv" 10 | "sync" 11 | "time" 12 | ) 13 | 14 | // RetryConfig configures retry behavior for API requests. 15 | type RetryConfig struct { 16 | // MaxRetries is the maximum number of retry attempts. 17 | MaxRetries int 18 | 19 | // InitialBackoff is the initial backoff duration. 20 | InitialBackoff time.Duration 21 | 22 | // MaxBackoff is the maximum backoff duration. 23 | MaxBackoff time.Duration 24 | 25 | // BackoffMultiplier is the multiplier applied to backoff after each retry. 26 | BackoffMultiplier float64 27 | 28 | // RetryOnStatusCodes specifies which HTTP status codes should trigger a retry. 29 | RetryOnStatusCodes []int 30 | 31 | // Jitter adds randomness to backoff to prevent thundering herd. 32 | Jitter bool 33 | } 34 | 35 | // DefaultRetryConfig returns the default retry configuration. 36 | func DefaultRetryConfig() *RetryConfig { 37 | return &RetryConfig{ 38 | MaxRetries: 3, 39 | InitialBackoff: 1 * time.Second, 40 | MaxBackoff: 30 * time.Second, 41 | BackoffMultiplier: 2.0, 42 | RetryOnStatusCodes: []int{429, 500, 502, 503, 504}, 43 | Jitter: true, 44 | } 45 | } 46 | 47 | // RateLimiter implements client-side rate limiting. 48 | type RateLimiter struct { 49 | mu sync.Mutex 50 | requestsLimit int 51 | windowSize time.Duration 52 | requests []time.Time 53 | } 54 | 55 | // NewRateLimiter creates a new rate limiter. 56 | // requestsPerSecond specifies the maximum requests per second allowed. 57 | func NewRateLimiter(requestsPerSecond int) *RateLimiter { 58 | return &RateLimiter{ 59 | requestsLimit: requestsPerSecond, 60 | windowSize: time.Second, 61 | requests: make([]time.Time, 0, requestsPerSecond), 62 | } 63 | } 64 | 65 | // Wait blocks until a request is allowed under the rate limit. 66 | func (r *RateLimiter) Wait(ctx context.Context) error { 67 | for { 68 | r.mu.Lock() 69 | 70 | now := time.Now() 71 | 72 | // Remove expired requests from the window 73 | cutoff := now.Add(-r.windowSize) 74 | validRequests := make([]time.Time, 0, len(r.requests)) 75 | for _, t := range r.requests { 76 | if t.After(cutoff) { 77 | validRequests = append(validRequests, t) 78 | } 79 | } 80 | r.requests = validRequests 81 | 82 | // Check if we're at the limit 83 | if len(r.requests) >= r.requestsLimit { 84 | // Calculate wait time until the oldest request expires 85 | oldestRequest := r.requests[0] 86 | waitTime := oldestRequest.Add(r.windowSize).Sub(now) 87 | r.mu.Unlock() 88 | 89 | if waitTime > 0 { 90 | select { 91 | case <-time.After(waitTime): 92 | // Retry the loop 93 | continue 94 | case <-ctx.Done(): 95 | return ctx.Err() 96 | } 97 | } 98 | continue 99 | } 100 | 101 | // Record this request and return 102 | r.requests = append(r.requests, time.Now()) 103 | r.mu.Unlock() 104 | return nil 105 | } 106 | } 107 | 108 | // RateLimitedClient wraps a Client with rate limiting and retry logic. 109 | type RateLimitedClient struct { 110 | *Client 111 | rateLimiter *RateLimiter 112 | retryConfig *RetryConfig 113 | } 114 | 115 | // NewRateLimitedClient creates a new client with rate limiting and retry logic. 116 | func NewRateLimitedClient(apiToken string, opts ...ClientOption) *RateLimitedClient { 117 | client := NewClient(apiToken, opts...) 118 | return &RateLimitedClient{ 119 | Client: client, 120 | rateLimiter: NewRateLimiter(10), // Default: 10 requests per second 121 | retryConfig: DefaultRetryConfig(), 122 | } 123 | } 124 | 125 | // SetRateLimit sets the rate limit for API requests. 126 | func (c *RateLimitedClient) SetRateLimit(requestsPerSecond int) { 127 | c.rateLimiter = NewRateLimiter(requestsPerSecond) 128 | } 129 | 130 | // SetRetryConfig sets the retry configuration. 131 | func (c *RateLimitedClient) SetRetryConfig(config *RetryConfig) { 132 | c.retryConfig = config 133 | } 134 | 135 | // DoRequestWithRetry performs a request with rate limiting and retry logic. 136 | // This method provides automatic retries with exponential backoff for transient errors. 137 | func (c *RateLimitedClient) DoRequestWithRetry(ctx context.Context, method, path string, query, body, result interface{}) error { 138 | var lastErr error 139 | 140 | for attempt := 0; attempt <= c.retryConfig.MaxRetries; attempt++ { 141 | // Wait for rate limit 142 | if err := c.rateLimiter.Wait(ctx); err != nil { 143 | return err 144 | } 145 | 146 | // Make the request 147 | err := c.Client.doRequest(ctx, method, path, nil, body, result) 148 | if err == nil { 149 | return nil 150 | } 151 | 152 | lastErr = err 153 | 154 | // Check if we should retry 155 | if !c.shouldRetry(err, attempt) { 156 | return err 157 | } 158 | 159 | // Calculate backoff 160 | backoff := c.calculateBackoff(attempt, err) 161 | 162 | // Wait before retrying 163 | select { 164 | case <-time.After(backoff): 165 | case <-ctx.Done(): 166 | return ctx.Err() 167 | } 168 | } 169 | 170 | return lastErr 171 | } 172 | 173 | // shouldRetry determines if a request should be retried. 174 | func (c *RateLimitedClient) shouldRetry(err error, attempt int) bool { 175 | if attempt >= c.retryConfig.MaxRetries { 176 | return false 177 | } 178 | 179 | apiErr, ok := IsAPIError(err) 180 | if !ok { 181 | // Network errors should be retried 182 | return true 183 | } 184 | 185 | // Check if status code is in retry list 186 | for _, code := range c.retryConfig.RetryOnStatusCodes { 187 | if apiErr.StatusCode == code { 188 | return true 189 | } 190 | } 191 | 192 | return false 193 | } 194 | 195 | // calculateBackoff calculates the backoff duration for a retry attempt. 196 | func (c *RateLimitedClient) calculateBackoff(attempt int, err error) time.Duration { 197 | // Check for Retry-After header hint 198 | if apiErr, ok := IsAPIError(err); ok && apiErr.StatusCode == http.StatusTooManyRequests { 199 | // In a real implementation, we'd parse the Retry-After header 200 | // For now, use a reasonable default for rate limiting 201 | return 60 * time.Second 202 | } 203 | 204 | // Exponential backoff 205 | backoff := float64(c.retryConfig.InitialBackoff) * math.Pow(c.retryConfig.BackoffMultiplier, float64(attempt)) 206 | 207 | // Apply jitter 208 | if c.retryConfig.Jitter { 209 | // Use crypto/rand for secure random number generation 210 | randInt, randErr := rand.Int(rand.Reader, big.NewInt(1000)) 211 | if randErr == nil { 212 | jitter := (float64(randInt.Int64()) / 1000.0) * 0.3 * backoff // Up to 30% jitter 213 | backoff += jitter 214 | } 215 | } 216 | 217 | // Cap at max backoff 218 | if backoff > float64(c.retryConfig.MaxBackoff) { 219 | backoff = float64(c.retryConfig.MaxBackoff) 220 | } 221 | 222 | return time.Duration(backoff) 223 | } 224 | 225 | // RateLimitInfo contains rate limit information from API response headers. 226 | type RateLimitInfo struct { 227 | // Limit is the maximum number of requests allowed per window. 228 | Limit int 229 | 230 | // Remaining is the number of requests remaining in the current window. 231 | Remaining int 232 | 233 | // Reset is the time when the rate limit window resets. 234 | Reset time.Time 235 | } 236 | 237 | // ParseRateLimitHeaders parses rate limit information from HTTP response headers. 238 | func ParseRateLimitHeaders(headers http.Header) *RateLimitInfo { 239 | info := &RateLimitInfo{} 240 | 241 | if limit := headers.Get("X-RateLimit-Limit"); limit != "" { 242 | info.Limit, _ = strconv.Atoi(limit) 243 | } 244 | 245 | if remaining := headers.Get("X-RateLimit-Remaining"); remaining != "" { 246 | info.Remaining, _ = strconv.Atoi(remaining) 247 | } 248 | 249 | // Note: Invoice Ninja may not provide a reset timestamp 250 | // This is a placeholder for when it does 251 | if reset := headers.Get("X-RateLimit-Reset"); reset != "" { 252 | if timestamp, err := strconv.ParseInt(reset, 10, 64); err == nil { 253 | info.Reset = time.Unix(timestamp, 0) 254 | } 255 | } 256 | 257 | return info 258 | } 259 | -------------------------------------------------------------------------------- /retry_test.go: -------------------------------------------------------------------------------- 1 | package invoiceninja 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "sync" 7 | "sync/atomic" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestDefaultRetryConfig(t *testing.T) { 13 | config := DefaultRetryConfig() 14 | 15 | if config.MaxRetries != 3 { 16 | t.Errorf("expected MaxRetries=3, got %d", config.MaxRetries) 17 | } 18 | 19 | if config.InitialBackoff != 1*time.Second { 20 | t.Errorf("expected InitialBackoff=1s, got %v", config.InitialBackoff) 21 | } 22 | 23 | if config.MaxBackoff != 30*time.Second { 24 | t.Errorf("expected MaxBackoff=30s, got %v", config.MaxBackoff) 25 | } 26 | 27 | if config.BackoffMultiplier != 2.0 { 28 | t.Errorf("expected BackoffMultiplier=2.0, got %f", config.BackoffMultiplier) 29 | } 30 | 31 | if !config.Jitter { 32 | t.Error("expected Jitter=true") 33 | } 34 | 35 | expectedCodes := []int{429, 500, 502, 503, 504} 36 | if len(config.RetryOnStatusCodes) != len(expectedCodes) { 37 | t.Errorf("expected %d retry status codes, got %d", len(expectedCodes), len(config.RetryOnStatusCodes)) 38 | } 39 | } 40 | 41 | func TestRateLimiter(t *testing.T) { 42 | limiter := NewRateLimiter(5) // 5 requests per second 43 | 44 | ctx := context.Background() 45 | start := time.Now() 46 | 47 | // Make 5 requests quickly - should all succeed immediately 48 | for i := 0; i < 5; i++ { 49 | if err := limiter.Wait(ctx); err != nil { 50 | t.Fatalf("unexpected error: %v", err) 51 | } 52 | } 53 | 54 | elapsed := time.Since(start) 55 | if elapsed > 100*time.Millisecond { 56 | t.Errorf("first 5 requests should be immediate, took %v", elapsed) 57 | } 58 | } 59 | 60 | func TestRateLimiterConcurrent(t *testing.T) { 61 | limiter := NewRateLimiter(10) 62 | ctx := context.Background() 63 | 64 | var wg sync.WaitGroup 65 | var count int32 66 | 67 | // Launch 10 concurrent requests 68 | for i := 0; i < 10; i++ { 69 | wg.Add(1) 70 | go func() { 71 | defer wg.Done() 72 | if err := limiter.Wait(ctx); err != nil { 73 | t.Errorf("unexpected error: %v", err) 74 | } 75 | atomic.AddInt32(&count, 1) 76 | }() 77 | } 78 | 79 | wg.Wait() 80 | 81 | if count != 10 { 82 | t.Errorf("expected 10 requests to complete, got %d", count) 83 | } 84 | } 85 | 86 | func TestRateLimiterContextCancellation(t *testing.T) { 87 | limiter := NewRateLimiter(1) 88 | 89 | ctx := context.Background() 90 | 91 | // Use up the rate limit 92 | if err := limiter.Wait(ctx); err != nil { 93 | t.Fatalf("unexpected error: %v", err) 94 | } 95 | 96 | // Cancel context immediately 97 | cancelCtx, cancel := context.WithCancel(context.Background()) 98 | cancel() 99 | 100 | // This should return immediately with context error 101 | err := limiter.Wait(cancelCtx) 102 | if err == nil { 103 | t.Error("expected context cancellation error") 104 | } 105 | } 106 | 107 | func TestNewRateLimitedClient(t *testing.T) { 108 | client := NewRateLimitedClient("test-token") 109 | 110 | if client.Client == nil { 111 | t.Error("expected embedded Client to be initialized") 112 | } 113 | 114 | if client.rateLimiter == nil { 115 | t.Error("expected rateLimiter to be initialized") 116 | } 117 | 118 | if client.retryConfig == nil { 119 | t.Error("expected retryConfig to be initialized") 120 | } 121 | } 122 | 123 | func TestRateLimitedClientSetRateLimit(t *testing.T) { 124 | client := NewRateLimitedClient("test-token") 125 | 126 | client.SetRateLimit(20) 127 | 128 | if client.rateLimiter.requestsLimit != 20 { 129 | t.Errorf("expected rate limit 20, got %d", client.rateLimiter.requestsLimit) 130 | } 131 | } 132 | 133 | func TestRateLimitedClientSetRetryConfig(t *testing.T) { 134 | client := NewRateLimitedClient("test-token") 135 | 136 | customConfig := &RetryConfig{ 137 | MaxRetries: 5, 138 | InitialBackoff: 2 * time.Second, 139 | MaxBackoff: 60 * time.Second, 140 | } 141 | 142 | client.SetRetryConfig(customConfig) 143 | 144 | if client.retryConfig.MaxRetries != 5 { 145 | t.Errorf("expected MaxRetries=5, got %d", client.retryConfig.MaxRetries) 146 | } 147 | } 148 | 149 | func TestParseRateLimitHeaders(t *testing.T) { 150 | headers := http.Header{} 151 | headers.Set("X-RateLimit-Limit", "100") 152 | headers.Set("X-RateLimit-Remaining", "95") 153 | 154 | info := ParseRateLimitHeaders(headers) 155 | 156 | if info.Limit != 100 { 157 | t.Errorf("expected Limit=100, got %d", info.Limit) 158 | } 159 | 160 | if info.Remaining != 95 { 161 | t.Errorf("expected Remaining=95, got %d", info.Remaining) 162 | } 163 | } 164 | 165 | func TestParseRateLimitHeadersEmpty(t *testing.T) { 166 | headers := http.Header{} 167 | 168 | info := ParseRateLimitHeaders(headers) 169 | 170 | if info.Limit != 0 { 171 | t.Errorf("expected Limit=0 for empty headers, got %d", info.Limit) 172 | } 173 | 174 | if info.Remaining != 0 { 175 | t.Errorf("expected Remaining=0 for empty headers, got %d", info.Remaining) 176 | } 177 | } 178 | 179 | func TestShouldRetry(t *testing.T) { 180 | client := NewRateLimitedClient("test-token") 181 | 182 | tests := []struct { 183 | name string 184 | err error 185 | attempt int 186 | expected bool 187 | }{ 188 | { 189 | name: "rate limited error", 190 | err: &APIError{StatusCode: 429}, 191 | attempt: 0, 192 | expected: true, 193 | }, 194 | { 195 | name: "server error 500", 196 | err: &APIError{StatusCode: 500}, 197 | attempt: 0, 198 | expected: true, 199 | }, 200 | { 201 | name: "server error 503", 202 | err: &APIError{StatusCode: 503}, 203 | attempt: 0, 204 | expected: true, 205 | }, 206 | { 207 | name: "client error 400", 208 | err: &APIError{StatusCode: 400}, 209 | attempt: 0, 210 | expected: false, 211 | }, 212 | { 213 | name: "not found error", 214 | err: &APIError{StatusCode: 404}, 215 | attempt: 0, 216 | expected: false, 217 | }, 218 | { 219 | name: "max retries exceeded", 220 | err: &APIError{StatusCode: 500}, 221 | attempt: 3, 222 | expected: false, 223 | }, 224 | } 225 | 226 | for _, tt := range tests { 227 | t.Run(tt.name, func(t *testing.T) { 228 | result := client.shouldRetry(tt.err, tt.attempt) 229 | if result != tt.expected { 230 | t.Errorf("shouldRetry() = %v, want %v", result, tt.expected) 231 | } 232 | }) 233 | } 234 | } 235 | 236 | func TestCalculateBackoff(t *testing.T) { 237 | client := NewRateLimitedClient("test-token") 238 | client.retryConfig.Jitter = false // Disable jitter for predictable tests 239 | 240 | tests := []struct { 241 | name string 242 | attempt int 243 | expected time.Duration 244 | }{ 245 | { 246 | name: "first attempt", 247 | attempt: 0, 248 | expected: 1 * time.Second, 249 | }, 250 | { 251 | name: "second attempt", 252 | attempt: 1, 253 | expected: 2 * time.Second, 254 | }, 255 | { 256 | name: "third attempt", 257 | attempt: 2, 258 | expected: 4 * time.Second, 259 | }, 260 | } 261 | 262 | for _, tt := range tests { 263 | t.Run(tt.name, func(t *testing.T) { 264 | result := client.calculateBackoff(tt.attempt, &APIError{StatusCode: 500}) 265 | if result != tt.expected { 266 | t.Errorf("calculateBackoff() = %v, want %v", result, tt.expected) 267 | } 268 | }) 269 | } 270 | } 271 | 272 | func TestCalculateBackoffMaxCap(t *testing.T) { 273 | client := NewRateLimitedClient("test-token") 274 | client.retryConfig.Jitter = false 275 | client.retryConfig.MaxBackoff = 5 * time.Second 276 | 277 | // After several attempts, backoff should be capped 278 | backoff := client.calculateBackoff(10, &APIError{StatusCode: 500}) 279 | if backoff > client.retryConfig.MaxBackoff { 280 | t.Errorf("backoff %v exceeded max %v", backoff, client.retryConfig.MaxBackoff) 281 | } 282 | } 283 | 284 | func TestCalculateBackoffRateLimited(t *testing.T) { 285 | client := NewRateLimitedClient("test-token") 286 | 287 | // Rate limited errors should have longer backoff 288 | backoff := client.calculateBackoff(0, &APIError{StatusCode: 429}) 289 | if backoff != 60*time.Second { 290 | t.Errorf("expected 60s backoff for rate limited, got %v", backoff) 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /docs/api-reference.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | This document provides a detailed reference for all available SDK methods. 4 | 5 | ## Client 6 | 7 | ### Creating a Client 8 | 9 | ```go 10 | client := invoiceninja.NewClient(apiToken string, opts ...Option) 11 | ``` 12 | 13 | ### Options 14 | 15 | | Option | Description | 16 | |--------|-------------| 17 | | `WithBaseURL(url)` | Set custom base URL | 18 | | `WithHTTPClient(client)` | Use custom HTTP client | 19 | | `WithTimeout(duration)` | Set request timeout | 20 | | `WithRateLimiter(limiter)` | Enable rate limiting | 21 | | `WithRetryConfig(config)` | Configure retry behavior | 22 | 23 | --- 24 | 25 | ## Payments Service 26 | 27 | ### List Payments 28 | 29 | ```go 30 | payments, err := client.Payments.List(ctx, &PaymentListOptions{ 31 | PerPage: int, // Items per page (default: 20) 32 | Page: int, // Page number 33 | ClientID: string, // Filter by client 34 | Status: string, // Filter by status 35 | Sort: string, // Sort field (e.g., "amount|desc") 36 | CreatedAt: int, // Filter by created timestamp 37 | UpdatedAt: int, // Filter by updated timestamp 38 | IsDeleted: bool, // Include deleted 39 | }) 40 | ``` 41 | 42 | ### Get Payment 43 | 44 | ```go 45 | payment, err := client.Payments.Get(ctx, paymentID string) 46 | ``` 47 | 48 | ### Create Payment 49 | 50 | ```go 51 | payment, err := client.Payments.Create(ctx, &PaymentRequest{ 52 | ClientID: string, // Required 53 | Amount: float64, // Required 54 | Date: string, // Payment date 55 | TypeID: string, // Payment type 56 | TransactionRef: string, // Reference number 57 | PrivateNotes: string, // Internal notes 58 | Invoices: []PaymentInvoice, // Applied invoices 59 | Credits: []PaymentCredit, // Applied credits 60 | }) 61 | ``` 62 | 63 | ### Update Payment 64 | 65 | ```go 66 | payment, err := client.Payments.Update(ctx, paymentID string, &PaymentRequest{...}) 67 | ``` 68 | 69 | ### Delete Payment 70 | 71 | ```go 72 | err := client.Payments.Delete(ctx, paymentID string) 73 | ``` 74 | 75 | ### Refund Payment 76 | 77 | ```go 78 | payment, err := client.Payments.Refund(ctx, &RefundRequest{ 79 | ID: string, // Payment ID 80 | Amount: float64, // Refund amount 81 | Invoices: []RefundInvoice, 82 | Date: string, 83 | }) 84 | ``` 85 | 86 | ### Bulk Actions 87 | 88 | ```go 89 | err := client.Payments.Bulk(ctx, &BulkActionRequest{ 90 | Action: string, // "archive", "restore", "delete" 91 | IDs: []string, // Payment IDs 92 | }) 93 | ``` 94 | 95 | --- 96 | 97 | ## Invoices Service 98 | 99 | ### List Invoices 100 | 101 | ```go 102 | invoices, err := client.Invoices.List(ctx, &InvoiceListOptions{ 103 | PerPage: int, 104 | Page: int, 105 | ClientID: string, 106 | Status: string, 107 | Sort: string, 108 | }) 109 | ``` 110 | 111 | ### Get Invoice 112 | 113 | ```go 114 | invoice, err := client.Invoices.Get(ctx, invoiceID string) 115 | ``` 116 | 117 | ### Create Invoice 118 | 119 | ```go 120 | invoice, err := client.Invoices.Create(ctx, &Invoice{ 121 | ClientID: string, // Required 122 | Date: string, // Invoice date 123 | DueDate: string, // Due date 124 | LineItems: []LineItem, // Invoice items 125 | PublicNotes: string, // Client-visible notes 126 | Terms: string, // Payment terms 127 | Footer: string, // Footer text 128 | Discount: float64, // Discount amount 129 | TaxName1: string, // Tax name 130 | TaxRate1: float64, // Tax rate 131 | }) 132 | ``` 133 | 134 | ### Update Invoice 135 | 136 | ```go 137 | invoice, err := client.Invoices.Update(ctx, invoiceID string, &Invoice{...}) 138 | ``` 139 | 140 | ### Delete Invoice 141 | 142 | ```go 143 | err := client.Invoices.Delete(ctx, invoiceID string) 144 | ``` 145 | 146 | ### Download PDF 147 | 148 | ```go 149 | pdfBytes, err := client.Downloads.Invoice(ctx, invitationKey string) 150 | ``` 151 | 152 | ### Bulk Actions 153 | 154 | ```go 155 | err := client.Invoices.Bulk(ctx, &BulkActionRequest{ 156 | Action: string, // "archive", "restore", "delete", "mark_sent", "mark_paid" 157 | IDs: []string, 158 | }) 159 | ``` 160 | 161 | --- 162 | 163 | ## Clients Service 164 | 165 | ### List Clients 166 | 167 | ```go 168 | clients, err := client.Clients.List(ctx, &ClientListOptions{ 169 | PerPage: int, 170 | Page: int, 171 | Status: string, 172 | Sort: string, 173 | }) 174 | ``` 175 | 176 | ### Get Client 177 | 178 | ```go 179 | c, err := client.Clients.Get(ctx, clientID string) 180 | ``` 181 | 182 | ### Create Client 183 | 184 | ```go 185 | c, err := client.Clients.Create(ctx, &Client{ 186 | Name: string, // Required 187 | DisplayName: string, 188 | Address1: string, 189 | Address2: string, 190 | City: string, 191 | State: string, 192 | PostalCode: string, 193 | CountryID: string, 194 | Phone: string, 195 | Website: string, 196 | PrivateNotes: string, 197 | PublicNotes: string, 198 | VATNumber: string, 199 | IDNumber: string, 200 | Contacts: []ClientContact, 201 | }) 202 | ``` 203 | 204 | ### Update Client 205 | 206 | ```go 207 | c, err := client.Clients.Update(ctx, clientID string, &Client{...}) 208 | ``` 209 | 210 | ### Delete Client 211 | 212 | ```go 213 | err := client.Clients.Delete(ctx, clientID string) 214 | ``` 215 | 216 | ### Merge Clients 217 | 218 | ```go 219 | c, err := client.Clients.Merge(ctx, targetClientID, sourceClientID string) 220 | ``` 221 | 222 | --- 223 | 224 | ## Credits Service 225 | 226 | ### List Credits 227 | 228 | ```go 229 | credits, err := client.Credits.List(ctx, &CreditListOptions{...}) 230 | ``` 231 | 232 | ### Get Credit 233 | 234 | ```go 235 | credit, err := client.Credits.Get(ctx, creditID string) 236 | ``` 237 | 238 | ### Create Credit 239 | 240 | ```go 241 | credit, err := client.Credits.Create(ctx, &Credit{ 242 | ClientID: string, 243 | Amount: float64, 244 | Date: string, 245 | LineItems: []LineItem, 246 | }) 247 | ``` 248 | 249 | ### Update Credit 250 | 251 | ```go 252 | credit, err := client.Credits.Update(ctx, creditID string, &Credit{...}) 253 | ``` 254 | 255 | ### Delete Credit 256 | 257 | ```go 258 | err := client.Credits.Delete(ctx, creditID string) 259 | ``` 260 | 261 | --- 262 | 263 | ## Payment Terms Service 264 | 265 | ### List Payment Terms 266 | 267 | ```go 268 | terms, err := client.PaymentTerms.List(ctx, &PaymentTermListOptions{...}) 269 | ``` 270 | 271 | ### Get Payment Term 272 | 273 | ```go 274 | term, err := client.PaymentTerms.Get(ctx, termID string) 275 | ``` 276 | 277 | ### Create Payment Term 278 | 279 | ```go 280 | term, err := client.PaymentTerms.Create(ctx, &PaymentTerm{ 281 | Name: string, // e.g., "Net 30" 282 | NumDays: int, // e.g., 30 283 | }) 284 | ``` 285 | 286 | ### Update Payment Term 287 | 288 | ```go 289 | term, err := client.PaymentTerms.Update(ctx, termID string, &PaymentTerm{...}) 290 | ``` 291 | 292 | ### Delete Payment Term 293 | 294 | ```go 295 | err := client.PaymentTerms.Delete(ctx, termID string) 296 | ``` 297 | 298 | --- 299 | 300 | ## Webhooks Service 301 | 302 | ### List Webhooks 303 | 304 | ```go 305 | webhooks, err := client.Webhooks.List(ctx, &WebhookListOptions{...}) 306 | ``` 307 | 308 | ### Create Webhook 309 | 310 | ```go 311 | webhook, err := client.Webhooks.Create(ctx, &Webhook{ 312 | TargetURL: string, // Your webhook endpoint 313 | EventID: string, // Event to subscribe to 314 | Format: string, // "JSON" 315 | }) 316 | ``` 317 | 318 | ### Delete Webhook 319 | 320 | ```go 321 | err := client.Webhooks.Delete(ctx, webhookID string) 322 | ``` 323 | 324 | ### Webhook Handler 325 | 326 | ```go 327 | handler := invoiceninja.NewWebhookHandler(secret string) 328 | 329 | // Verify signature 330 | valid := handler.VerifySignature(body []byte, signature string) 331 | 332 | // Parse event 333 | event, err := handler.ParseEvent(body []byte) 334 | ``` 335 | 336 | --- 337 | 338 | ## Generic Requests 339 | 340 | For endpoints not covered by specialized methods: 341 | 342 | ```go 343 | var result json.RawMessage 344 | err := client.Request(ctx, method, path string, body, result interface{}) 345 | ``` 346 | 347 | Example: 348 | 349 | ```go 350 | var activities []map[string]interface{} 351 | err := client.Request(ctx, "GET", "/api/v1/activities", nil, &activities) 352 | ``` 353 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | // Example demonstrates basic usage of the Invoice Ninja SDK. 2 | // 3 | // To run this example: 4 | // 5 | // go run example_test.go 6 | package invoiceninja_test 7 | 8 | import ( 9 | "context" 10 | "encoding/json" 11 | "fmt" 12 | "log" 13 | "net/url" 14 | 15 | invoiceninja "github.com/AshkanYarmoradi/go-invoice-ninja" 16 | ) 17 | 18 | func Example_basicUsage() { 19 | // Create a new client with your API token 20 | client := invoiceninja.NewClient("your-api-token") 21 | 22 | // For self-hosted instances, specify your base URL: 23 | // client := invoiceninja.NewClient("your-api-token", 24 | // invoiceninja.WithBaseURL("https://your-instance.com")) 25 | 26 | ctx := context.Background() 27 | 28 | // List payments with pagination 29 | payments, err := client.Payments.List(ctx, &invoiceninja.PaymentListOptions{ 30 | PerPage: 10, 31 | Page: 1, 32 | }) 33 | if err != nil { 34 | log.Fatal(err) 35 | } 36 | 37 | fmt.Printf("Found %d payments\n", len(payments.Data)) 38 | for _, p := range payments.Data { 39 | fmt.Printf(" - %s: $%.2f\n", p.Number, p.Amount) 40 | } 41 | } 42 | 43 | func Example_createPayment() { 44 | client := invoiceninja.NewClient("your-api-token") 45 | ctx := context.Background() 46 | 47 | // Create a payment for an invoice 48 | payment, err := client.Payments.Create(ctx, &invoiceninja.PaymentRequest{ 49 | ClientID: "client-hashed-id", 50 | Amount: 250.00, 51 | Date: "2024-01-15", 52 | Invoices: []invoiceninja.PaymentInvoice{ 53 | { 54 | InvoiceID: "invoice-hashed-id", 55 | Amount: 250.00, 56 | }, 57 | }, 58 | TransactionRef: "TXN-12345", 59 | PrivateNotes: "Payment received via bank transfer", 60 | }) 61 | if err != nil { 62 | log.Fatal(err) 63 | } 64 | 65 | fmt.Printf("Created payment: %s\n", payment.ID) 66 | } 67 | 68 | func Example_createInvoice() { 69 | client := invoiceninja.NewClient("your-api-token") 70 | ctx := context.Background() 71 | 72 | // Create an invoice with line items 73 | invoice, err := client.Invoices.Create(ctx, &invoiceninja.Invoice{ 74 | ClientID: "client-hashed-id", 75 | Date: "2024-01-15", 76 | DueDate: "2024-02-15", 77 | LineItems: []invoiceninja.LineItem{ 78 | { 79 | ProductKey: "Consulting Services", 80 | Notes: "January 2024 consulting work", 81 | Quantity: 10, 82 | Cost: 150.00, 83 | }, 84 | { 85 | ProductKey: "Support Hours", 86 | Notes: "Technical support", 87 | Quantity: 5, 88 | Cost: 75.00, 89 | }, 90 | }, 91 | PublicNotes: "Thank you for your business!", 92 | Terms: "Net 30", 93 | }) 94 | if err != nil { 95 | log.Fatal(err) 96 | } 97 | 98 | fmt.Printf("Created invoice: %s (Amount: $%.2f)\n", invoice.Number, invoice.Amount) 99 | } 100 | 101 | func Example_createClient() { 102 | client := invoiceninja.NewClient("your-api-token") 103 | ctx := context.Background() 104 | 105 | // Create a new client with contact 106 | newClient, err := client.Clients.Create(ctx, &invoiceninja.INClient{ 107 | Name: "Acme Corporation", 108 | Website: "https://acme.com", 109 | Phone: "+1 555-1234", 110 | Address1: "123 Main Street", 111 | City: "San Francisco", 112 | State: "CA", 113 | PostalCode: "94102", 114 | CountryID: "840", // USA 115 | Contacts: []invoiceninja.ClientContact{ 116 | { 117 | FirstName: "John", 118 | LastName: "Doe", 119 | Email: "john.doe@acme.com", 120 | Phone: "+1 555-5678", 121 | IsPrimary: true, 122 | }, 123 | { 124 | FirstName: "Jane", 125 | LastName: "Smith", 126 | Email: "jane.smith@acme.com", 127 | IsPrimary: false, 128 | }, 129 | }, 130 | }) 131 | if err != nil { 132 | log.Fatal(err) 133 | } 134 | 135 | fmt.Printf("Created client: %s (ID: %s)\n", newClient.Name, newClient.ID) 136 | } 137 | 138 | func Example_refundPayment() { 139 | client := invoiceninja.NewClient("your-api-token") 140 | ctx := context.Background() 141 | 142 | // Process a partial refund 143 | payment, err := client.Payments.Refund(ctx, &invoiceninja.RefundRequest{ 144 | ID: "payment-hashed-id", 145 | Amount: 50.00, 146 | GatewayRefund: true, // Process refund through payment gateway 147 | SendEmail: true, // Send refund notification email 148 | }) 149 | if err != nil { 150 | log.Fatal(err) 151 | } 152 | 153 | fmt.Printf("Refunded $%.2f from payment %s\n", payment.Refunded, payment.Number) 154 | } 155 | 156 | func Example_errorHandling() { 157 | client := invoiceninja.NewClient("your-api-token") 158 | ctx := context.Background() 159 | 160 | payment, err := client.Payments.Get(ctx, "non-existent-id") 161 | if err != nil { 162 | if apiErr, ok := invoiceninja.IsAPIError(err); ok { 163 | switch { 164 | case apiErr.IsNotFound(): 165 | fmt.Println("Payment not found") 166 | case apiErr.IsUnauthorized(): 167 | fmt.Println("Invalid or expired API token") 168 | case apiErr.IsForbidden(): 169 | fmt.Println("You don't have permission to access this resource") 170 | case apiErr.IsValidationError(): 171 | fmt.Println("Validation errors:") 172 | for field, errors := range apiErr.Errors { 173 | fmt.Printf(" %s: %v\n", field, errors) 174 | } 175 | case apiErr.IsRateLimited(): 176 | fmt.Println("Rate limit exceeded, please wait before retrying") 177 | case apiErr.IsServerError(): 178 | fmt.Println("Server error, please try again later") 179 | default: 180 | fmt.Printf("API error (status %d): %s\n", apiErr.StatusCode, apiErr.Message) 181 | } 182 | } else { 183 | fmt.Printf("Network or other error: %v\n", err) 184 | } 185 | return 186 | } 187 | 188 | fmt.Printf("Payment: %s\n", payment.Number) 189 | } 190 | 191 | func Example_genericRequest() { 192 | client := invoiceninja.NewClient("your-api-token") 193 | ctx := context.Background() 194 | 195 | // Access any endpoint not covered by specialized methods 196 | // Example: Get activities 197 | var activities json.RawMessage 198 | err := client.Request(ctx, "GET", "/api/v1/activities", nil, &activities) 199 | if err != nil { 200 | log.Fatal(err) 201 | } 202 | 203 | fmt.Printf("Activities response: %s\n", string(activities)) 204 | 205 | // Example: With query parameters 206 | query := url.Values{} 207 | query.Set("per_page", "50") 208 | query.Set("page", "1") 209 | 210 | var products json.RawMessage 211 | err = client.RequestWithQuery(ctx, "GET", "/api/v1/products", query, nil, &products) 212 | if err != nil { 213 | log.Fatal(err) 214 | } 215 | 216 | fmt.Printf("Products response: %s\n", string(products)) 217 | } 218 | 219 | func Example_bulkOperations() { 220 | client := invoiceninja.NewClient("your-api-token") 221 | ctx := context.Background() 222 | 223 | // Archive multiple payments at once 224 | paymentIDs := []string{"payment-id-1", "payment-id-2", "payment-id-3"} 225 | archivedPayments, err := client.Payments.Bulk(ctx, "archive", paymentIDs) 226 | if err != nil { 227 | log.Fatal(err) 228 | } 229 | 230 | fmt.Printf("Archived %d payments\n", len(archivedPayments)) 231 | 232 | // Mark multiple invoices as sent 233 | invoiceIDs := []string{"invoice-id-1", "invoice-id-2"} 234 | sentInvoices, err := client.Invoices.Bulk(ctx, "mark_sent", invoiceIDs) 235 | if err != nil { 236 | log.Fatal(err) 237 | } 238 | 239 | fmt.Printf("Marked %d invoices as sent\n", len(sentInvoices)) 240 | } 241 | 242 | func Example_pagination() { 243 | client := invoiceninja.NewClient("your-api-token") 244 | ctx := context.Background() 245 | 246 | // Iterate through all pages of clients 247 | page := 1 248 | perPage := 20 249 | 250 | for { 251 | clients, err := client.Clients.List(ctx, &invoiceninja.ClientListOptions{ 252 | PerPage: perPage, 253 | Page: page, 254 | }) 255 | if err != nil { 256 | log.Fatal(err) 257 | } 258 | 259 | fmt.Printf("Page %d: %d clients\n", page, len(clients.Data)) 260 | 261 | for _, c := range clients.Data { 262 | fmt.Printf(" - %s (Balance: $%.2f)\n", c.Name, c.Balance) 263 | } 264 | 265 | // Check if there are more pages 266 | if page >= clients.Meta.Pagination.TotalPages { 267 | break 268 | } 269 | page++ 270 | } 271 | } 272 | 273 | func Example_filtering() { 274 | client := invoiceninja.NewClient("your-api-token") 275 | ctx := context.Background() 276 | 277 | // Filter payments by client and date range 278 | payments, err := client.Payments.List(ctx, &invoiceninja.PaymentListOptions{ 279 | ClientID: "client-hashed-id", 280 | CreatedAt: "2024-01-01", 281 | Status: "active", 282 | Sort: "amount|desc", 283 | }) 284 | if err != nil { 285 | log.Fatal(err) 286 | } 287 | 288 | fmt.Printf("Found %d payments for client\n", len(payments.Data)) 289 | 290 | // Filter clients by balance 291 | clients, err := client.Clients.List(ctx, &invoiceninja.ClientListOptions{ 292 | Balance: "gt:1000", // Balance greater than $1000 293 | Sort: "balance|desc", 294 | Include: "contacts", // Include contact information 295 | }) 296 | if err != nil { 297 | log.Fatal(err) 298 | } 299 | 300 | fmt.Printf("Found %d clients with balance > $1000\n", len(clients.Data)) 301 | } 302 | -------------------------------------------------------------------------------- /integration_test.go: -------------------------------------------------------------------------------- 1 | // Package invoiceninja_test provides integration tests for the Invoice Ninja SDK. 2 | // 3 | // To run these tests against the demo API: 4 | // 5 | // go test -tags=integration -v ./... 6 | // 7 | // To run against a custom server: 8 | // 9 | // INVOICE_NINJA_BASE_URL=https://your-server.com \ 10 | // INVOICE_NINJA_API_TOKEN=your-token \ 11 | // go test -tags=integration -v ./... 12 | // 13 | //go:build integration 14 | // +build integration 15 | 16 | package invoiceninja_test 17 | 18 | import ( 19 | "context" 20 | "os" 21 | "testing" 22 | "time" 23 | 24 | invoiceninja "github.com/AshkanYarmoradi/go-invoice-ninja" 25 | ) 26 | 27 | var ( 28 | testClient *invoiceninja.Client 29 | ) 30 | 31 | func TestMain(m *testing.M) { 32 | baseURL := os.Getenv("INVOICE_NINJA_BASE_URL") 33 | if baseURL == "" { 34 | baseURL = invoiceninja.DemoBaseURL 35 | } 36 | 37 | apiToken := os.Getenv("INVOICE_NINJA_API_TOKEN") 38 | if apiToken == "" { 39 | apiToken = "TOKEN" // Demo API token 40 | } 41 | 42 | testClient = invoiceninja.NewClient(apiToken, 43 | invoiceninja.WithBaseURL(baseURL), 44 | invoiceninja.WithTimeout(30*time.Second), 45 | ) 46 | 47 | os.Exit(m.Run()) 48 | } 49 | 50 | func TestIntegration_ListPayments(t *testing.T) { 51 | ctx := context.Background() 52 | 53 | payments, err := testClient.Payments.List(ctx, &invoiceninja.PaymentListOptions{ 54 | PerPage: 5, 55 | Page: 1, 56 | }) 57 | if err != nil { 58 | t.Fatalf("failed to list payments: %v", err) 59 | } 60 | 61 | t.Logf("Found %d payments (page 1)", len(payments.Data)) 62 | 63 | for _, p := range payments.Data { 64 | t.Logf(" Payment %s: $%.2f", p.Number, p.Amount) 65 | } 66 | } 67 | 68 | func TestIntegration_ListInvoices(t *testing.T) { 69 | ctx := context.Background() 70 | 71 | invoices, err := testClient.Invoices.List(ctx, &invoiceninja.InvoiceListOptions{ 72 | PerPage: 5, 73 | Page: 1, 74 | }) 75 | if err != nil { 76 | t.Fatalf("failed to list invoices: %v", err) 77 | } 78 | 79 | t.Logf("Found %d invoices (page 1)", len(invoices.Data)) 80 | 81 | for _, inv := range invoices.Data { 82 | t.Logf(" Invoice %s: $%.2f (balance: $%.2f)", inv.Number, inv.Amount, inv.Balance) 83 | } 84 | } 85 | 86 | func TestIntegration_ListClients(t *testing.T) { 87 | ctx := context.Background() 88 | 89 | clients, err := testClient.Clients.List(ctx, &invoiceninja.ClientListOptions{ 90 | PerPage: 5, 91 | Page: 1, 92 | }) 93 | if err != nil { 94 | t.Fatalf("failed to list clients: %v", err) 95 | } 96 | 97 | t.Logf("Found %d clients (page 1)", len(clients.Data)) 98 | 99 | for _, c := range clients.Data { 100 | t.Logf(" Client %s (balance: $%.2f)", c.Name, c.Balance) 101 | } 102 | } 103 | 104 | func TestIntegration_ListPaymentTerms(t *testing.T) { 105 | ctx := context.Background() 106 | 107 | terms, err := testClient.PaymentTerms.List(ctx, nil) 108 | if err != nil { 109 | t.Fatalf("failed to list payment terms: %v", err) 110 | } 111 | 112 | t.Logf("Found %d payment terms", len(terms.Data)) 113 | 114 | for _, term := range terms.Data { 115 | t.Logf(" %s: %d days", term.Name, term.NumDays) 116 | } 117 | } 118 | 119 | func TestIntegration_ListCredits(t *testing.T) { 120 | ctx := context.Background() 121 | 122 | credits, err := testClient.Credits.List(ctx, &invoiceninja.CreditListOptions{ 123 | PerPage: 5, 124 | Page: 1, 125 | }) 126 | if err != nil { 127 | t.Fatalf("failed to list credits: %v", err) 128 | } 129 | 130 | t.Logf("Found %d credits (page 1)", len(credits.Data)) 131 | 132 | for _, credit := range credits.Data { 133 | t.Logf(" Credit %s: $%.2f", credit.Number, credit.Amount) 134 | } 135 | } 136 | 137 | func TestIntegration_GetPayment(t *testing.T) { 138 | ctx := context.Background() 139 | 140 | // First, get a list of payments to find an ID 141 | payments, err := testClient.Payments.List(ctx, &invoiceninja.PaymentListOptions{ 142 | PerPage: 1, 143 | }) 144 | if err != nil { 145 | t.Fatalf("failed to list payments: %v", err) 146 | } 147 | 148 | if len(payments.Data) == 0 { 149 | t.Skip("No payments available to test Get") 150 | } 151 | 152 | paymentID := payments.Data[0].ID 153 | 154 | // Now get the specific payment 155 | payment, err := testClient.Payments.Get(ctx, paymentID) 156 | if err != nil { 157 | t.Fatalf("failed to get payment: %v", err) 158 | } 159 | 160 | t.Logf("Got payment: %s ($%.2f)", payment.Number, payment.Amount) 161 | } 162 | 163 | func TestIntegration_GetInvoice(t *testing.T) { 164 | ctx := context.Background() 165 | 166 | // First, get a list of invoices to find an ID 167 | invoices, err := testClient.Invoices.List(ctx, &invoiceninja.InvoiceListOptions{ 168 | PerPage: 1, 169 | }) 170 | if err != nil { 171 | t.Fatalf("failed to list invoices: %v", err) 172 | } 173 | 174 | if len(invoices.Data) == 0 { 175 | t.Skip("No invoices available to test Get") 176 | } 177 | 178 | invoiceID := invoices.Data[0].ID 179 | 180 | // Now get the specific invoice 181 | invoice, err := testClient.Invoices.Get(ctx, invoiceID) 182 | if err != nil { 183 | t.Fatalf("failed to get invoice: %v", err) 184 | } 185 | 186 | t.Logf("Got invoice: %s ($%.2f)", invoice.Number, invoice.Amount) 187 | } 188 | 189 | func TestIntegration_GetClient(t *testing.T) { 190 | ctx := context.Background() 191 | 192 | // First, get a list of clients to find an ID 193 | clients, err := testClient.Clients.List(ctx, &invoiceninja.ClientListOptions{ 194 | PerPage: 1, 195 | }) 196 | if err != nil { 197 | t.Fatalf("failed to list clients: %v", err) 198 | } 199 | 200 | if len(clients.Data) == 0 { 201 | t.Skip("No clients available to test Get") 202 | } 203 | 204 | clientID := clients.Data[0].ID 205 | 206 | // Now get the specific client 207 | client, err := testClient.Clients.Get(ctx, clientID) 208 | if err != nil { 209 | t.Fatalf("failed to get client: %v", err) 210 | } 211 | 212 | t.Logf("Got client: %s (balance: $%.2f)", client.Name, client.Balance) 213 | } 214 | 215 | func TestIntegration_Pagination(t *testing.T) { 216 | ctx := context.Background() 217 | 218 | // Test pagination by getting multiple pages 219 | page1, err := testClient.Invoices.List(ctx, &invoiceninja.InvoiceListOptions{ 220 | PerPage: 5, 221 | Page: 1, 222 | }) 223 | if err != nil { 224 | t.Fatalf("failed to get page 1: %v", err) 225 | } 226 | 227 | t.Logf("Page 1: %d invoices, Total: %d, TotalPages: %d", 228 | len(page1.Data), 229 | page1.Meta.Pagination.Total, 230 | page1.Meta.Pagination.TotalPages) 231 | 232 | if page1.Meta.Pagination.TotalPages > 1 { 233 | page2, err := testClient.Invoices.List(ctx, &invoiceninja.InvoiceListOptions{ 234 | PerPage: 5, 235 | Page: 2, 236 | }) 237 | if err != nil { 238 | t.Fatalf("failed to get page 2: %v", err) 239 | } 240 | 241 | t.Logf("Page 2: %d invoices", len(page2.Data)) 242 | 243 | // Ensure we got different data 244 | if len(page1.Data) > 0 && len(page2.Data) > 0 { 245 | if page1.Data[0].ID == page2.Data[0].ID { 246 | t.Error("expected different invoices on different pages") 247 | } 248 | } 249 | } 250 | } 251 | 252 | func TestIntegration_GenericRequest(t *testing.T) { 253 | ctx := context.Background() 254 | 255 | // Use the generic request method to access activities 256 | var result struct { 257 | Data []struct { 258 | ID string `json:"id"` 259 | } `json:"data"` 260 | } 261 | 262 | err := testClient.Request(ctx, "GET", "/api/v1/activities", nil, &result) 263 | if err != nil { 264 | t.Fatalf("failed to get activities: %v", err) 265 | } 266 | 267 | t.Logf("Found %d activities", len(result.Data)) 268 | } 269 | 270 | func TestIntegration_ErrorHandling(t *testing.T) { 271 | ctx := context.Background() 272 | 273 | // Try to get a non-existent payment 274 | _, err := testClient.Payments.Get(ctx, "nonexistent-id-12345") 275 | if err == nil { 276 | t.Error("expected error for non-existent payment") 277 | return 278 | } 279 | 280 | apiErr, ok := invoiceninja.IsAPIError(err) 281 | if !ok { 282 | t.Errorf("expected APIError, got %T", err) 283 | return 284 | } 285 | 286 | t.Logf("Got expected error: %v (status: %d)", apiErr.Message, apiErr.StatusCode) 287 | 288 | // Should be either 404 (not found) or 400 (bad request for invalid ID format) 289 | if apiErr.StatusCode != 404 && apiErr.StatusCode != 400 && apiErr.StatusCode != 422 { 290 | t.Errorf("expected status 404, 400, or 422, got %d", apiErr.StatusCode) 291 | } 292 | } 293 | 294 | func TestIntegration_Filtering(t *testing.T) { 295 | ctx := context.Background() 296 | 297 | // Test filtering invoices by status 298 | activeInvoices, err := testClient.Invoices.List(ctx, &invoiceninja.InvoiceListOptions{ 299 | Status: "active", 300 | PerPage: 5, 301 | }) 302 | if err != nil { 303 | t.Fatalf("failed to list active invoices: %v", err) 304 | } 305 | 306 | t.Logf("Found %d active invoices", len(activeInvoices.Data)) 307 | 308 | // Test sorting 309 | sortedInvoices, err := testClient.Invoices.List(ctx, &invoiceninja.InvoiceListOptions{ 310 | Sort: "amount|desc", 311 | PerPage: 5, 312 | }) 313 | if err != nil { 314 | t.Fatalf("failed to list sorted invoices: %v", err) 315 | } 316 | 317 | t.Logf("Found %d invoices (sorted by amount desc)", len(sortedInvoices.Data)) 318 | 319 | // Verify sorting order 320 | if len(sortedInvoices.Data) >= 2 { 321 | if sortedInvoices.Data[0].Amount < sortedInvoices.Data[1].Amount { 322 | t.Log("Note: Invoice amounts may not be strictly sorted if amounts are equal") 323 | } 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /invoices_test.go: -------------------------------------------------------------------------------- 1 | package invoiceninja 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | ) 10 | 11 | func TestInvoicesServiceList(t *testing.T) { 12 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 13 | if r.Method != "GET" { 14 | t.Errorf("expected GET method, got %s", r.Method) 15 | } 16 | if r.URL.Path != "/api/v1/invoices" { 17 | t.Errorf("expected path /api/v1/invoices, got %s", r.URL.Path) 18 | } 19 | 20 | w.Header().Set("Content-Type", "application/json") 21 | json.NewEncoder(w).Encode(map[string]interface{}{ 22 | "data": []map[string]interface{}{ 23 | {"id": "inv123", "number": "INV001", "amount": 500.00}, 24 | {"id": "inv456", "number": "INV002", "amount": 750.00}, 25 | }, 26 | "meta": map[string]interface{}{ 27 | "pagination": map[string]interface{}{ 28 | "total": 25, 29 | "count": 2, 30 | "per_page": 20, 31 | "current_page": 1, 32 | "total_pages": 2, 33 | }, 34 | }, 35 | }) 36 | })) 37 | defer server.Close() 38 | 39 | client := NewClient("test-token", WithBaseURL(server.URL)) 40 | 41 | resp, err := client.Invoices.List(context.Background(), nil) 42 | if err != nil { 43 | t.Fatalf("unexpected error: %v", err) 44 | } 45 | 46 | if len(resp.Data) != 2 { 47 | t.Errorf("expected 2 invoices, got %d", len(resp.Data)) 48 | } 49 | 50 | if resp.Data[0].Number != "INV001" { 51 | t.Errorf("expected first invoice number to be 'INV001', got '%s'", resp.Data[0].Number) 52 | } 53 | } 54 | 55 | func TestInvoicesServiceGet(t *testing.T) { 56 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 57 | if r.Method != "GET" { 58 | t.Errorf("expected GET method, got %s", r.Method) 59 | } 60 | if r.URL.Path != "/api/v1/invoices/inv123" { 61 | t.Errorf("expected path /api/v1/invoices/inv123, got %s", r.URL.Path) 62 | } 63 | 64 | w.Header().Set("Content-Type", "application/json") 65 | json.NewEncoder(w).Encode(map[string]interface{}{ 66 | "data": map[string]interface{}{ 67 | "id": "inv123", 68 | "number": "INV001", 69 | "client_id": "client123", 70 | "amount": 500.00, 71 | "balance": 250.00, 72 | }, 73 | }) 74 | })) 75 | defer server.Close() 76 | 77 | client := NewClient("test-token", WithBaseURL(server.URL)) 78 | 79 | invoice, err := client.Invoices.Get(context.Background(), "inv123") 80 | if err != nil { 81 | t.Fatalf("unexpected error: %v", err) 82 | } 83 | 84 | if invoice.ID != "inv123" { 85 | t.Errorf("expected invoice ID to be 'inv123', got '%s'", invoice.ID) 86 | } 87 | 88 | if invoice.Balance != 250.00 { 89 | t.Errorf("expected balance to be 250.00, got %f", invoice.Balance) 90 | } 91 | } 92 | 93 | func TestInvoicesServiceCreate(t *testing.T) { 94 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 95 | if r.Method != "POST" { 96 | t.Errorf("expected POST method, got %s", r.Method) 97 | } 98 | if r.URL.Path != "/api/v1/invoices" { 99 | t.Errorf("expected path /api/v1/invoices, got %s", r.URL.Path) 100 | } 101 | 102 | var body Invoice 103 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 104 | t.Errorf("failed to decode request body: %v", err) 105 | } 106 | 107 | if body.ClientID != "client123" { 108 | t.Errorf("expected client_id to be 'client123', got '%s'", body.ClientID) 109 | } 110 | 111 | w.Header().Set("Content-Type", "application/json") 112 | json.NewEncoder(w).Encode(map[string]interface{}{ 113 | "data": map[string]interface{}{ 114 | "id": "newinv123", 115 | "client_id": "client123", 116 | "number": "INV003", 117 | }, 118 | }) 119 | })) 120 | defer server.Close() 121 | 122 | client := NewClient("test-token", WithBaseURL(server.URL)) 123 | 124 | req := &Invoice{ 125 | ClientID: "client123", 126 | LineItems: []LineItem{ 127 | {ProductKey: "Product A", Quantity: 2, Cost: 100.00}, 128 | }, 129 | } 130 | 131 | invoice, err := client.Invoices.Create(context.Background(), req) 132 | if err != nil { 133 | t.Fatalf("unexpected error: %v", err) 134 | } 135 | 136 | if invoice.ID != "newinv123" { 137 | t.Errorf("expected invoice ID to be 'newinv123', got '%s'", invoice.ID) 138 | } 139 | } 140 | 141 | func TestInvoicesServiceUpdate(t *testing.T) { 142 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 143 | if r.Method != "PUT" { 144 | t.Errorf("expected PUT method, got %s", r.Method) 145 | } 146 | if r.URL.Path != "/api/v1/invoices/inv123" { 147 | t.Errorf("expected path /api/v1/invoices/inv123, got %s", r.URL.Path) 148 | } 149 | 150 | w.Header().Set("Content-Type", "application/json") 151 | json.NewEncoder(w).Encode(map[string]interface{}{ 152 | "data": map[string]interface{}{ 153 | "id": "inv123", 154 | "po_number": "PO-12345", 155 | }, 156 | }) 157 | })) 158 | defer server.Close() 159 | 160 | client := NewClient("test-token", WithBaseURL(server.URL)) 161 | 162 | req := &Invoice{ 163 | PONumber: "PO-12345", 164 | } 165 | 166 | invoice, err := client.Invoices.Update(context.Background(), "inv123", req) 167 | if err != nil { 168 | t.Fatalf("unexpected error: %v", err) 169 | } 170 | 171 | if invoice.ID != "inv123" { 172 | t.Errorf("expected invoice ID to be 'inv123', got '%s'", invoice.ID) 173 | } 174 | } 175 | 176 | func TestInvoicesServiceDelete(t *testing.T) { 177 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 178 | if r.Method != "DELETE" { 179 | t.Errorf("expected DELETE method, got %s", r.Method) 180 | } 181 | if r.URL.Path != "/api/v1/invoices/inv123" { 182 | t.Errorf("expected path /api/v1/invoices/inv123, got %s", r.URL.Path) 183 | } 184 | 185 | w.WriteHeader(http.StatusOK) 186 | })) 187 | defer server.Close() 188 | 189 | client := NewClient("test-token", WithBaseURL(server.URL)) 190 | 191 | err := client.Invoices.Delete(context.Background(), "inv123") 192 | if err != nil { 193 | t.Fatalf("unexpected error: %v", err) 194 | } 195 | } 196 | 197 | func TestInvoicesServiceBulk(t *testing.T) { 198 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 199 | if r.Method != "POST" { 200 | t.Errorf("expected POST method, got %s", r.Method) 201 | } 202 | if r.URL.Path != "/api/v1/invoices/bulk" { 203 | t.Errorf("expected path /api/v1/invoices/bulk, got %s", r.URL.Path) 204 | } 205 | 206 | var body BulkAction 207 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 208 | t.Errorf("failed to decode request body: %v", err) 209 | } 210 | 211 | if body.Action != "mark_paid" { 212 | t.Errorf("expected action to be 'mark_paid', got '%s'", body.Action) 213 | } 214 | 215 | w.Header().Set("Content-Type", "application/json") 216 | json.NewEncoder(w).Encode(map[string]interface{}{ 217 | "data": []map[string]interface{}{ 218 | {"id": "inv123", "status_id": "4"}, 219 | }, 220 | }) 221 | })) 222 | defer server.Close() 223 | 224 | client := NewClient("test-token", WithBaseURL(server.URL)) 225 | 226 | invoices, err := client.Invoices.Bulk(context.Background(), "mark_paid", []string{"inv123"}) 227 | if err != nil { 228 | t.Fatalf("unexpected error: %v", err) 229 | } 230 | 231 | if len(invoices) != 1 { 232 | t.Errorf("expected 1 invoice, got %d", len(invoices)) 233 | } 234 | } 235 | 236 | func TestInvoicesServiceMarkPaid(t *testing.T) { 237 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 238 | w.Header().Set("Content-Type", "application/json") 239 | json.NewEncoder(w).Encode(map[string]interface{}{ 240 | "data": []map[string]interface{}{ 241 | {"id": "inv123", "status_id": "4"}, 242 | }, 243 | }) 244 | })) 245 | defer server.Close() 246 | 247 | client := NewClient("test-token", WithBaseURL(server.URL)) 248 | 249 | invoice, err := client.Invoices.MarkPaid(context.Background(), "inv123") 250 | if err != nil { 251 | t.Fatalf("unexpected error: %v", err) 252 | } 253 | 254 | if invoice.ID != "inv123" { 255 | t.Errorf("expected invoice ID to be 'inv123', got '%s'", invoice.ID) 256 | } 257 | } 258 | 259 | func TestInvoiceListOptionsToQuery(t *testing.T) { 260 | isDeleted := false 261 | opts := &InvoiceListOptions{ 262 | PerPage: 25, 263 | Page: 3, 264 | Filter: "search term", 265 | ClientID: "client456", 266 | Status: "active", 267 | CreatedAt: "2024-02-01", 268 | UpdatedAt: "2024-02-15", 269 | IsDeleted: &isDeleted, 270 | Sort: "number|asc", 271 | Include: "payments", 272 | } 273 | 274 | q := opts.toQuery() 275 | 276 | if q.Get("per_page") != "25" { 277 | t.Errorf("expected per_page=25, got %s", q.Get("per_page")) 278 | } 279 | if q.Get("page") != "3" { 280 | t.Errorf("expected page=3, got %s", q.Get("page")) 281 | } 282 | if q.Get("filter") != "search term" { 283 | t.Errorf("expected filter='search term', got %s", q.Get("filter")) 284 | } 285 | if q.Get("client_id") != "client456" { 286 | t.Errorf("expected client_id=client456, got %s", q.Get("client_id")) 287 | } 288 | if q.Get("is_deleted") != "false" { 289 | t.Errorf("expected is_deleted=false, got %s", q.Get("is_deleted")) 290 | } 291 | } 292 | 293 | func TestInvoiceListOptionsNilToQuery(t *testing.T) { 294 | var opts *InvoiceListOptions = nil 295 | q := opts.toQuery() 296 | if q != nil { 297 | t.Error("expected nil query for nil options") 298 | } 299 | } 300 | --------------------------------------------------------------------------------