├── .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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------