mirror of
https://github.com/chenasraf/cospend-cli.git
synced 2026-05-18 01:39:03 +00:00
488 lines
14 KiB
Go
488 lines
14 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/chenasraf/cospend-cli/internal/config"
|
|
)
|
|
|
|
// Client is the Cospend API client
|
|
type Client struct {
|
|
config *config.Config
|
|
httpClient *http.Client
|
|
Debug bool
|
|
DebugWriter io.Writer
|
|
}
|
|
|
|
// Member represents a project member
|
|
type Member struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
UserID string `json:"userid"`
|
|
Activated bool `json:"activated"`
|
|
}
|
|
|
|
// Category represents a bill category
|
|
type Category struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// PaymentMode represents a payment method
|
|
type PaymentMode struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// Currency represents a currency
|
|
type Currency struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
ExchangeRate float64 `json:"exchange_rate"`
|
|
}
|
|
|
|
// Project represents a Cospend project
|
|
type Project struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
CurrencyName string `json:"currencyname"`
|
|
Members []Member `json:"members"`
|
|
Categories []Category // custom unmarshal
|
|
PaymentModes []PaymentMode // custom unmarshal
|
|
Currencies []Currency `json:"currencies"`
|
|
}
|
|
|
|
// UnmarshalJSON custom unmarshaler to handle categories/paymentmodes as object or array
|
|
func (p *Project) UnmarshalJSON(data []byte) error {
|
|
// Use a map for flexible parsing
|
|
var raw map[string]json.RawMessage
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Parse simple fields
|
|
if v, ok := raw["id"]; ok {
|
|
_ = json.Unmarshal(v, &p.ID)
|
|
}
|
|
if v, ok := raw["name"]; ok {
|
|
_ = json.Unmarshal(v, &p.Name)
|
|
}
|
|
if v, ok := raw["currencyname"]; ok {
|
|
_ = json.Unmarshal(v, &p.CurrencyName)
|
|
}
|
|
if v, ok := raw["members"]; ok {
|
|
_ = json.Unmarshal(v, &p.Members)
|
|
}
|
|
if v, ok := raw["currencies"]; ok {
|
|
_ = json.Unmarshal(v, &p.Currencies)
|
|
}
|
|
|
|
// Parse categories (can be array or object)
|
|
if v, ok := raw["categories"]; ok {
|
|
p.Categories = parseCategories(v)
|
|
}
|
|
|
|
// Parse payment modes (can be array or object)
|
|
if v, ok := raw["paymentmodes"]; ok {
|
|
p.PaymentModes = parsePaymentModes(v)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MarshalJSON custom marshaler for Project
|
|
func (p Project) MarshalJSON() ([]byte, error) {
|
|
return json.Marshal(struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
CurrencyName string `json:"currencyname"`
|
|
Members []Member `json:"members"`
|
|
Categories []Category `json:"categories"`
|
|
PaymentModes []PaymentMode `json:"paymentmodes"`
|
|
Currencies []Currency `json:"currencies"`
|
|
}{
|
|
ID: p.ID,
|
|
Name: p.Name,
|
|
CurrencyName: p.CurrencyName,
|
|
Members: p.Members,
|
|
Categories: p.Categories,
|
|
PaymentModes: p.PaymentModes,
|
|
Currencies: p.Currencies,
|
|
})
|
|
}
|
|
|
|
func parseCategories(data json.RawMessage) []Category {
|
|
// API returns categories as object keyed by ID
|
|
var obj map[string]Category
|
|
if err := json.Unmarshal(data, &obj); err == nil {
|
|
result := make([]Category, 0, len(obj))
|
|
for _, cat := range obj {
|
|
result = append(result, cat)
|
|
}
|
|
return result
|
|
}
|
|
// Fallback to array (for tests)
|
|
var arr []Category
|
|
if err := json.Unmarshal(data, &arr); err == nil {
|
|
return arr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parsePaymentModes(data json.RawMessage) []PaymentMode {
|
|
// API returns payment modes as object keyed by ID
|
|
var obj map[string]PaymentMode
|
|
if err := json.Unmarshal(data, &obj); err == nil {
|
|
result := make([]PaymentMode, 0, len(obj))
|
|
for _, pm := range obj {
|
|
result = append(result, pm)
|
|
}
|
|
return result
|
|
}
|
|
// Fallback to array (for tests)
|
|
var arr []PaymentMode
|
|
if err := json.Unmarshal(data, &arr); err == nil {
|
|
return arr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Bill represents a bill to create
|
|
type Bill struct {
|
|
What string `json:"what"`
|
|
Amount float64 `json:"amount"`
|
|
PayerID int `json:"payer_id"`
|
|
OwedTo []int `json:"-"` // Will be formatted as comma-separated string
|
|
Date string `json:"date"`
|
|
Comment string `json:"comment,omitempty"`
|
|
PaymentModeID int `json:"paymentmodeid,omitempty"`
|
|
CategoryID int `json:"categoryid,omitempty"`
|
|
OriginalCurrencyID int `json:"original_currency_id,omitempty"`
|
|
}
|
|
|
|
// BillResponse represents a bill returned from the API
|
|
type BillResponse struct {
|
|
ID int `json:"id"`
|
|
What string `json:"what"`
|
|
Amount float64 `json:"amount"`
|
|
Date string `json:"date"`
|
|
PayerID int `json:"payer_id"`
|
|
Owers []Ower `json:"owers"`
|
|
Comment string `json:"comment"`
|
|
PaymentModeID int `json:"paymentmodeid"`
|
|
CategoryID int `json:"categoryid"`
|
|
Repeat string `json:"repeat"`
|
|
Timestamp int64 `json:"timestamp"`
|
|
}
|
|
|
|
// Ower represents a member who owes part of a bill
|
|
type Ower struct {
|
|
ID int `json:"id"`
|
|
Weight float64 `json:"weight"`
|
|
}
|
|
|
|
// OCSResponse wraps the OCS API response format
|
|
type OCSResponse struct {
|
|
OCS struct {
|
|
Meta struct {
|
|
Status string `json:"status"`
|
|
StatusCode int `json:"statuscode"`
|
|
Message string `json:"message"`
|
|
} `json:"meta"`
|
|
Data json.RawMessage `json:"data"`
|
|
} `json:"ocs"`
|
|
}
|
|
|
|
// NewClient creates a new API client
|
|
func NewClient(cfg *config.Config) *Client {
|
|
return &Client{
|
|
config: cfg,
|
|
httpClient: &http.Client{},
|
|
}
|
|
}
|
|
|
|
func (c *Client) debugf(format string, args ...interface{}) {
|
|
if c.Debug && c.DebugWriter != nil {
|
|
_, _ = fmt.Fprintf(c.DebugWriter, "[DEBUG] "+format+"\n", args...)
|
|
}
|
|
}
|
|
|
|
func (c *Client) doRequest(method, path string, body io.Reader) (*http.Response, error) {
|
|
baseURL := config.NormalizeURL(c.config.Domain)
|
|
fullURL := fmt.Sprintf("%s%s", baseURL, path)
|
|
|
|
c.debugf("Request: %s %s", method, fullURL)
|
|
|
|
req, err := http.NewRequest(method, fullURL, body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating request: %w", err)
|
|
}
|
|
|
|
req.SetBasicAuth(c.config.User, c.config.Password)
|
|
req.Header.Set("OCS-APIRequest", "true")
|
|
req.Header.Set("Accept", "application/json")
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
}
|
|
|
|
c.debugf("Headers: OCS-APIRequest=true, Accept=application/json, Auth=Basic %s:***", c.config.User)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
c.debugf("Request error: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
c.debugf("Response: %d %s", resp.StatusCode, resp.Status)
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// GetProject fetches project details including members, categories, and payment modes
|
|
func (c *Client) GetProject(projectID string) (*Project, error) {
|
|
path := fmt.Sprintf("/ocs/v2.php/apps/cospend/api/v1/projects/%s", url.PathEscape(projectID))
|
|
|
|
resp, err := c.doRequest("GET", path, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetching project: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var ocsResp OCSResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&ocsResp); err != nil {
|
|
return nil, fmt.Errorf("decoding response: %w", err)
|
|
}
|
|
|
|
if ocsResp.OCS.Meta.StatusCode != 200 {
|
|
return nil, fmt.Errorf("API error: %s", ocsResp.OCS.Meta.Message)
|
|
}
|
|
|
|
var project Project
|
|
if err := json.Unmarshal(ocsResp.OCS.Data, &project); err != nil {
|
|
return nil, fmt.Errorf("decoding project data: %w", err)
|
|
}
|
|
|
|
return &project, nil
|
|
}
|
|
|
|
// ProjectSummary represents a project in the list response
|
|
type ProjectSummary struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
CurrName string `json:"currencyname"`
|
|
ArchivedTS *int64 `json:"archived_ts"`
|
|
}
|
|
|
|
// IsArchived returns true if the project is archived
|
|
func (p *ProjectSummary) IsArchived() bool {
|
|
return p.ArchivedTS != nil
|
|
}
|
|
|
|
// GetProjects fetches all projects the user has access to
|
|
func (c *Client) GetProjects() ([]ProjectSummary, error) {
|
|
path := "/ocs/v2.php/apps/cospend/api/v1/projects"
|
|
|
|
resp, err := c.doRequest("GET", path, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetching projects: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var ocsResp OCSResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&ocsResp); err != nil {
|
|
return nil, fmt.Errorf("decoding response: %w", err)
|
|
}
|
|
|
|
if ocsResp.OCS.Meta.StatusCode != 200 {
|
|
return nil, fmt.Errorf("API error: %s", ocsResp.OCS.Meta.Message)
|
|
}
|
|
|
|
c.debugf("Projects response: %s", string(ocsResp.OCS.Data))
|
|
|
|
var projects []ProjectSummary
|
|
if err := json.Unmarshal(ocsResp.OCS.Data, &projects); err != nil {
|
|
return nil, fmt.Errorf("decoding projects data: %w", err)
|
|
}
|
|
|
|
return projects, nil
|
|
}
|
|
|
|
// CreateBill creates a new bill in the project
|
|
func (c *Client) CreateBill(projectID string, bill Bill) error {
|
|
path := fmt.Sprintf("/ocs/v2.php/apps/cospend/api/v1/projects/%s/bills", url.PathEscape(projectID))
|
|
|
|
// Build form data
|
|
data := url.Values{}
|
|
data.Set("what", bill.What)
|
|
data.Set("amount", strconv.FormatFloat(bill.Amount, 'f', 2, 64))
|
|
data.Set("payer", strconv.Itoa(bill.PayerID))
|
|
data.Set("date", bill.Date)
|
|
data.Set("timestamp", strconv.FormatInt(time.Now().Unix(), 10))
|
|
data.Set("repeat", "n")
|
|
|
|
// Format owed member IDs as comma-separated string
|
|
owedIDs := make([]string, len(bill.OwedTo))
|
|
for i, id := range bill.OwedTo {
|
|
owedIDs[i] = strconv.Itoa(id)
|
|
}
|
|
data.Set("payedFor", strings.Join(owedIDs, ","))
|
|
|
|
if bill.Comment != "" {
|
|
data.Set("comment", bill.Comment)
|
|
}
|
|
if bill.PaymentModeID != 0 {
|
|
data.Set("paymentmodeid", strconv.Itoa(bill.PaymentModeID))
|
|
}
|
|
if bill.CategoryID != 0 {
|
|
data.Set("categoryid", strconv.Itoa(bill.CategoryID))
|
|
}
|
|
if bill.OriginalCurrencyID != 0 {
|
|
data.Set("original_currency_id", strconv.Itoa(bill.OriginalCurrencyID))
|
|
}
|
|
|
|
c.debugf("Request body: %s", data.Encode())
|
|
|
|
resp, err := c.doRequest("POST", path, strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
return fmt.Errorf("creating bill: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var ocsResp OCSResponse
|
|
bodyBytes, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("reading response body: %w", err)
|
|
}
|
|
|
|
if err := json.NewDecoder(bytes.NewReader(bodyBytes)).Decode(&ocsResp); err != nil {
|
|
return fmt.Errorf("decoding response: %w", err)
|
|
}
|
|
|
|
if ocsResp.OCS.Meta.StatusCode != 200 {
|
|
return fmt.Errorf("API error: %s", ocsResp.OCS.Meta.Message)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetBills fetches all bills for a project
|
|
func (c *Client) GetBills(projectID string) ([]BillResponse, error) {
|
|
path := fmt.Sprintf("/ocs/v2.php/apps/cospend/api/v1/projects/%s/bills", url.PathEscape(projectID))
|
|
|
|
resp, err := c.doRequest("GET", path, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetching bills: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var ocsResp OCSResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&ocsResp); err != nil {
|
|
return nil, fmt.Errorf("decoding response: %w", err)
|
|
}
|
|
|
|
if ocsResp.OCS.Meta.StatusCode != 200 {
|
|
return nil, fmt.Errorf("API error: %s", ocsResp.OCS.Meta.Message)
|
|
}
|
|
|
|
// API returns: {"nb_bills": N, "bills": [...], "allBillIds": [...], "timestamp": N}
|
|
var billsWrapper struct {
|
|
Bills []BillResponse `json:"bills"`
|
|
}
|
|
if err := json.Unmarshal(ocsResp.OCS.Data, &billsWrapper); err != nil {
|
|
return nil, fmt.Errorf("decoding bills data: %w", err)
|
|
}
|
|
|
|
return billsWrapper.Bills, nil
|
|
}
|
|
|
|
// UserInfo represents Nextcloud user information
|
|
type UserInfo struct {
|
|
Locale string `json:"locale"`
|
|
Language string `json:"language"`
|
|
}
|
|
|
|
// GetUserInfo fetches the authenticated user's info from Nextcloud OCS API
|
|
func (c *Client) GetUserInfo() (*UserInfo, error) {
|
|
resp, err := c.doRequest("GET", "/ocs/v2.php/cloud/user", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetching user info: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var ocsResp OCSResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&ocsResp); err != nil {
|
|
return nil, fmt.Errorf("decoding response: %w", err)
|
|
}
|
|
|
|
if ocsResp.OCS.Meta.StatusCode != 200 {
|
|
return nil, fmt.Errorf("API error: %s", ocsResp.OCS.Meta.Message)
|
|
}
|
|
|
|
var userInfo UserInfo
|
|
if err := json.Unmarshal(ocsResp.OCS.Data, &userInfo); err != nil {
|
|
return nil, fmt.Errorf("decoding user info: %w", err)
|
|
}
|
|
|
|
return &userInfo, nil
|
|
}
|
|
|
|
// DeleteBill deletes a bill from the project
|
|
func (c *Client) DeleteBill(projectID string, billID int) error {
|
|
path := fmt.Sprintf("/ocs/v2.php/apps/cospend/api/v1/projects/%s/bills/%d", url.PathEscape(projectID), billID)
|
|
|
|
resp, err := c.doRequest("DELETE", path, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("deleting bill: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var ocsResp OCSResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&ocsResp); err != nil {
|
|
return fmt.Errorf("decoding response: %w", err)
|
|
}
|
|
|
|
if ocsResp.OCS.Meta.StatusCode != 200 {
|
|
return fmt.Errorf("API error: %s", ocsResp.OCS.Meta.Message)
|
|
}
|
|
|
|
return nil
|
|
}
|