From 0cd51ac40198034925bbcf311144505a7555816a Mon Sep 17 00:00:00 2001 From: Emmanuel Ozeh Date: Wed, 7 May 2025 15:38:31 +0100 Subject: [PATCH 01/13] Clean-up: Tweak for clarity --- README.md | 50 +++++++++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index d195b05..2607c99 100644 --- a/README.md +++ b/README.md @@ -7,18 +7,22 @@ paystack-go is a Go client library for accessing the Paystack API. Where possible, the services available on the client groups the API into logical chunks and correspond to the structure of the Paystack API documentation at https://developers.paystack.co/v1.0/reference. ## Usage - +Reference paystack-go in your go program: ``` go import "github.com/rpip/paystack-go" +``` +Initialize new Paystack client: +``` go apiKey := "sk_test_b748a89ad84f35c2f1a8b81681f956274de048bb" -// second param is an optional http client, allowing overriding of the HTTP client to use. -// This is useful if you're running in a Google AppEngine environment -// where the http.DefaultClient is not available. +// The second parameter is an optional HTTP client, allowing overriding of the HTTP client to use. This is useful if you're running in a Google AppEngine environment where the http.DefaultClient is not available. client := paystack.NewClient(apiKey) - -recipient := &TransferRecipient{ +``` +### Transfers +Create a TransferRecipient: +``` go +transferRecipient := &TransferRecipient{ Type: "Nuban", Name: "Customer 1", Description: "Demo customer", @@ -28,35 +32,35 @@ recipient := &TransferRecipient{ Metadata: map[string]interface{}{"job": "Plumber"}, } -recipient1, err := client.Transfer.CreateRecipient(recipient) - -req := &TransferRequest{ - Source: "balance", - Reason: "Delivery pickup", - Amount: 30, +recipient, err := client.Transfer.CreateRecipient(transferRecipient) +// You can store the RecipientCode(recipient.RecipientCode) and retrieve as desired for transfers +``` +Initiate transfer: +``` go +transferRequest := &TransferRequest{ + Source: "balance", // Funds to be transferred from your PayStack balance + Amount: 30, // In least denomination (Kobo if NGN, pesewas if GHS) Recipient: recipient1.RecipientCode, + Currency: "NGN" // Optional. Defaults to NGN + Reason: "Delivery pickup", // Optional + } -transfer, err := client.Transfer.Initiate(req) +transfer, err := client.Transfer.Initiate(transferRequest) if err != nil { // do something with error } - -// retrieve list of plans -plans, err := client.Plan.List() - -for i, plan := range plans.Values { - fmt.Printf("%+v", plan) -} - -cust := &Customer{ +``` +### Customers +``` go +customer := &Customer{ FirstName: "User123", LastName: "AdminUser", Email: "user123@gmail.com", Phone: "+23400000000000000", } // create the customer -customer, err := client.Customer.Create(cust) +customer, err := client.Customer.Create(customer) if err != nil { // do something with error } From 18e4639bd71b6cd2e760b43f39ded739bc7ea3fe Mon Sep 17 00:00:00 2001 From: Emmanuel Ozeh Date: Mon, 12 May 2025 00:40:06 +0100 Subject: [PATCH 02/13] Add Transaction Split service --- paystack.go | 1 + split.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 split.go diff --git a/paystack.go b/paystack.go index 8c78c13..76921de 100644 --- a/paystack.go +++ b/paystack.go @@ -59,6 +59,7 @@ type Client struct { Charge *ChargeService Bank *BankService BulkCharge *BulkChargeService + Split *SplitService LoggingEnabled bool Log Logger diff --git a/split.go b/split.go new file mode 100644 index 0000000..803d969 --- /dev/null +++ b/split.go @@ -0,0 +1,49 @@ +package paystack + +// SplitService handles operations related to transaction Splits +// For more details see https://paystack.com/docs/api/split/ +type SplitService service + +type SplitRequest struct { + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Currency string `json:"currency,omitempty"` + Subaccounts []BeneficiaryAccount `json:"subaccounts,omitempty"` + BearerType string `json:"bearer_type,omitempty"` // Any of "subaccount" | "account" | "all-proportional" | "all" + BearerSubAccount string `json:"bearer_subaccount,omitempty"` // SubAccountCode of bearer, if SubAccount +} + +type BeneficiaryAccount struct { + Subaccount SubAccount `json:"subaccount,omitempty"` + Share int `json:"share,omitempty"` +} + +type CreateSplitResponse struct { + Status bool `json:"status,omitempty"` + Message string `json:"message,omitempty"` + Data SplitResponseData `json:"data,omitempty"` +} + +type SplitResponseData struct { + SplitID int `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Currency string `json:"currency,omitempty"` + Integration int `json:"integration,omitempty"` + Domain string `json:"domain,omitempty"` + SplitCode string `json:"split_code,omitempty"` + Active bool `json:"active,omitempty"` + BearerType string `json:"bearer_type,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + IsDynamic bool `json:"is_dynamic,omitempty"` + Subaccounts []BeneficiaryAccount `json:"subaccounts,omitempty"` + TotalSubAccounts int `json:"total_subaccounts,omitempty"` +} + +func (s *SplitService) CreateSplit(request *SplitRequest) (*CreateSplitResponse, error) { + url := "/split" + response := &CreateSplitResponse{} + err := s.client.Call("POST", url, request, response) + return response, err +} From b5928c5c9a463818c6a9ff2981a7fd72b15c78b0 Mon Sep 17 00:00:00 2001 From: Emmanuel Ozeh Date: Tue, 13 May 2025 00:07:04 +0100 Subject: [PATCH 03/13] Implement other Split methods --- paystack.go | 1 + split.go | 115 +++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 93 insertions(+), 23 deletions(-) diff --git a/paystack.go b/paystack.go index 76921de..90de0b4 100644 --- a/paystack.go +++ b/paystack.go @@ -127,6 +127,7 @@ func NewClient(key string, httpClient *http.Client) *Client { c.Charge = (*ChargeService)(&c.common) c.Bank = (*BankService)(&c.common) c.BulkCharge = (*BulkChargeService)(&c.common) + c.Split = (*SplitService)(&c.common) return c } diff --git a/split.go b/split.go index 803d969..41d4626 100644 --- a/split.go +++ b/split.go @@ -1,30 +1,13 @@ package paystack +import "fmt" + // SplitService handles operations related to transaction Splits // For more details see https://paystack.com/docs/api/split/ type SplitService service -type SplitRequest struct { - Name string `json:"name,omitempty"` - Type string `json:"type,omitempty"` - Currency string `json:"currency,omitempty"` - Subaccounts []BeneficiaryAccount `json:"subaccounts,omitempty"` - BearerType string `json:"bearer_type,omitempty"` // Any of "subaccount" | "account" | "all-proportional" | "all" - BearerSubAccount string `json:"bearer_subaccount,omitempty"` // SubAccountCode of bearer, if SubAccount -} - -type BeneficiaryAccount struct { - Subaccount SubAccount `json:"subaccount,omitempty"` - Share int `json:"share,omitempty"` -} - -type CreateSplitResponse struct { - Status bool `json:"status,omitempty"` - Message string `json:"message,omitempty"` - Data SplitResponseData `json:"data,omitempty"` -} - -type SplitResponseData struct { +// Represents a Paystack Split payment +type Split struct { SplitID int `json:"id,omitempty"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` @@ -41,9 +24,95 @@ type SplitResponseData struct { TotalSubAccounts int `json:"total_subaccounts,omitempty"` } -func (s *SplitService) CreateSplit(request *SplitRequest) (*CreateSplitResponse, error) { +// SplitRequest represents a request to create a transaction Split +type SplitRequest struct { + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Currency string `json:"currency,omitempty"` + Subaccounts []BeneficiaryAccount `json:"subaccounts,omitempty"` + BearerType string `json:"bearer_type,omitempty"` // Any of "subaccount", "account", "all-proportional","all" + BearerSubAccount string `json:"bearer_subaccount,omitempty"` // SubAccountCode of bearer, if SubAccount +} + +// SplitList is a list object for Splits. +type SplitList struct { + Meta ListMeta + Values []Split `json:"data"` +} + +// Represents a request to update a split +type SplitUpdateRequest struct { + Name string `json:"name,omitempty"` + Active bool `json:"active,omitempty"` + BearerType string `json:"bearer_type,omitempty"` // Any of "subaccount", "account", "all-proportional","all". + BearerSubAccount string `json:"bearer_subaccount,omitempty"` // SubAccountCode of bearer, if SubAccount. +} + +// BeneficiaryAccount represents a SubAccount paired with its allocated share of the Split +type BeneficiaryAccount struct { + Subaccount SubAccount `json:"subaccount,omitempty"` + Share int `json:"share,omitempty"` +} + +// Create a split payment on your integration +// For more details see https://paystack.com/docs/api/split/#create +func (s *SplitService) CreateSplit(request *SplitRequest) (*Split, error) { url := "/split" - response := &CreateSplitResponse{} + response := &Split{} err := s.client.Call("POST", url, request, response) return response, err } + +// List available transaction Splits +// For more details see https://paystack.com/docs/api/split/#list +func (s *SplitService) List() (*SplitList, error) { + return s.ListN(10, 1) +} + +// List available transaction Splits +// For more details see https://paystack.com/docs/api/split/#list +func (s *SplitService) ListN(count, offset int) (*SplitList, error) { + url := paginateURL("/split", count, offset) + splits := &SplitList{} + err := s.client.Call("GET", url, nil, splits) + return splits, err +} + +// Get details of Split with the specified id +// For more details see https://paystack.com/docs/api/split/#fetch +func (s *SplitService) Get(id int) (*Split, error) { + url := fmt.Sprintf("/split/%d", id) + split := &Split{} + err := s.client.Call("GET", url, nil, split) + return split, err +} + +// Update a transaction split details on your integration +// For more details see https://paystack.com/docs/api/split/#update +func (s *SplitService) Update(id int, request *SplitUpdateRequest) (*Split, error) { + url := fmt.Sprintf("split/%d", id) + split := &Split{} + err := s.client.Call("PUT", url, request, split) + return split, err +} + +// Add a Subaccount to a Transaction Split, or update the share of an existing Subaccount in a Transaction Split +// For more details see https://paystack.com/docs/api/split/#add-subaccount +func (s *SplitService) UpdateSubAccounts(splitID int, subAccountCode string, share int) (*Split, error) { + url := fmt.Sprintf("split/%d/subaccount/add", splitID) + split := &Split{} + requestData := map[string]interface{}{ + "subaccount": subAccountCode, + "share": share, + } + err := s.client.Call("POST", url, requestData, split) + return split, err +} + +// Remove a subaccount from a transaction split +// For more details see https://paystack.com/docs/api/split/#remove-subaccount +func (s *SplitService) RemoveSubAccount(splitID int, subAccountCode string) error { + url := fmt.Sprintf("split/%d/subaccount/remove", splitID) + err := s.client.Call("POST", url, subAccountCode, nil) + return err +} From 6884adcd068ad1078b3d54c9cbeacb26e0bc8398 Mon Sep 17 00:00:00 2001 From: Emmanuel Ozeh Date: Wed, 14 May 2025 04:46:13 +0100 Subject: [PATCH 04/13] Fix: SplitRequest needs only SubAccount code, not SubAccount --- split.go | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/split.go b/split.go index 41d4626..f1b3a47 100644 --- a/split.go +++ b/split.go @@ -26,12 +26,12 @@ type Split struct { // SplitRequest represents a request to create a transaction Split type SplitRequest struct { - Name string `json:"name,omitempty"` - Type string `json:"type,omitempty"` - Currency string `json:"currency,omitempty"` - Subaccounts []BeneficiaryAccount `json:"subaccounts,omitempty"` - BearerType string `json:"bearer_type,omitempty"` // Any of "subaccount", "account", "all-proportional","all" - BearerSubAccount string `json:"bearer_subaccount,omitempty"` // SubAccountCode of bearer, if SubAccount + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Currency string `json:"currency,omitempty"` + Subaccounts []BeneficiaryAccountRequest `json:"subaccounts,omitempty"` + BearerType string `json:"bearer_type,omitempty"` // Any of "subaccount", "account", "all-proportional","all" + BearerSubAccount string `json:"bearer_subaccount,omitempty"` // SubAccountCode of bearer, if SubAccount } // SplitList is a list object for Splits. @@ -54,6 +54,12 @@ type BeneficiaryAccount struct { Share int `json:"share,omitempty"` } +// Represents a SubAccount code paired with its allocated share of the split. Used in requests to create Splits. +type BeneficiaryAccountRequest struct { + SubAccountCode string `json:"subaccount,omitempty"` + Share string `json:"share,omitempty"` +} + // Create a split payment on your integration // For more details see https://paystack.com/docs/api/split/#create func (s *SplitService) CreateSplit(request *SplitRequest) (*Split, error) { From 6dc4bb1e35551ef7e41e628b0e3e233f672ef979 Mon Sep 17 00:00:00 2001 From: Emmanuel Ozeh Date: Fri, 16 May 2025 20:00:15 +0100 Subject: [PATCH 05/13] Fix: Share type should be integer --- split.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/split.go b/split.go index f1b3a47..687ef08 100644 --- a/split.go +++ b/split.go @@ -57,7 +57,7 @@ type BeneficiaryAccount struct { // Represents a SubAccount code paired with its allocated share of the split. Used in requests to create Splits. type BeneficiaryAccountRequest struct { SubAccountCode string `json:"subaccount,omitempty"` - Share string `json:"share,omitempty"` + Share int `json:"share,omitempty"` } // Create a split payment on your integration From 18918100ae699c82ae6f7373e4a4ec00224a2243 Mon Sep 17 00:00:00 2001 From: Emmanuel Ozeh Date: Fri, 16 May 2025 20:01:11 +0100 Subject: [PATCH 06/13] Add option for running a specific test --- Makefile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Makefile b/Makefile index 762b16b..73e4b1c 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,10 @@ test: fmt lint vet @echo "+ $@" @PAYSTACK_KEY=$(PAYSTACK_KEY) go test -v -tags "$(BUILDTAGS) cgo" $(shell go list ./... | grep -v vendor) +test-one: + @echo "+ $@" + @PAYSTACK_KEY=$(PAYSTACK_KEY) go test -v -tags "$(BUILDTAGS) cgo" -run "$(TEST)" $(shell go list ./... | grep -v vendor) + vet: @echo "+ $@" @go vet $(shell go list ./... | grep -v vendor) From d112bcddddb8d09ab59594964aa58d26522d4903 Mon Sep 17 00:00:00 2001 From: Emmanuel Ozeh Date: Fri, 16 May 2025 20:01:27 +0100 Subject: [PATCH 07/13] Add tests for Split service --- split_test.go | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 split_test.go diff --git a/split_test.go b/split_test.go new file mode 100644 index 0000000..58c525f --- /dev/null +++ b/split_test.go @@ -0,0 +1,88 @@ +package paystack + +import "testing" + +func TestSplitCRUD(t *testing.T) { + + subAccount := &SubAccount{ + BusinessName: "Sunshine Studios", + SettlementBank: "044", + AccountNumber: "0193278965", + PercentageCharge: 12.8, + } + + // create the subAccount + subAccount, err := c.SubAccount.Create(subAccount) + if err != nil { + t.Errorf("CREATE SubAccount returned error: %v", err) + } + + subaccount := BeneficiaryAccountRequest{ + SubAccountCode: subAccount.SubAccountCode, + Share: 20, + } + splitRequest := &SplitRequest{ + Name: "Halfsies", + Type: "percentage", + Currency: "NGN", + Subaccounts: []BeneficiaryAccountRequest{subaccount}, + } + + split, err := c.Split.CreateSplit(splitRequest) + if err != nil { + t.Errorf("CreateSplit returned error: %v", err) + } + + if split.SplitCode == "" { + t.Errorf("Expected SplitCode to be set") + } + + if split.Name != "Halfsies" { + t.Errorf("Expected Split name to be %v, got %v", splitRequest.Name, split.Name) + } + + // fetch the split + sameSplit, err := c.Split.Get(split.SplitID) + if err != nil { + t.Errorf("GET Spilt returned error: %v", err) + } + + if sameSplit.Name != split.Name { + t.Errorf("Expected Split Name to be %v, got %v", split.Name, sameSplit.Name) + } + + // retrieve the Split list + splits, err := c.Split.List() + if err != nil || !(len(splits.Values) > 0) || !(splits.Meta.Total > 0) { + t.Errorf("Expected Split list, got %d, returned error %v", len(splits.Values), err) + } + + // Test UPDATE Split + update := &SplitUpdateRequest{ + Name: "Royalty", + Active: true, + } + updatedSplit, err := c.Split.Update(sameSplit.SplitID, update) + if err != nil { + t.Errorf("Failed to UPDATE Split: %v", err) + } + if updatedSplit.Name != update.Name { + t.Errorf("Expected Split Name to be updated to %v, got %v", update.Name, updatedSplit.Name) + } + + // Test UPDATE Split SubAccounts + newShare := 50 + updatedSplit, err = c.Split.UpdateSubAccounts(split.SplitID, subAccount.SubAccountCode, newShare) + if err != nil { + t.Errorf("Failed to UPDATE Split SubAccounts: %v", err) + } + if updatedSplit.Subaccounts[0].Share != newShare { + t.Errorf("Expected Split SubAccount share to be updated to %v, got %v", newShare, updatedSplit.Subaccounts[0].Share) + } + + // Test DELETE + err = c.Split.RemoveSubAccount(split.SplitID, subAccount.SubAccountCode) + if err != nil || split.TotalSubAccounts != 0 { + t.Errorf("Failed to REMOVE Split SubAccount: %v", err) + } +} From a97fff60e557521978d1bc3e246cd7ed1c463724 Mon Sep 17 00:00:00 2001 From: Emmanuel Ozeh Date: Tue, 20 May 2025 08:39:19 +0100 Subject: [PATCH 08/13] Fix request structure for removing Split SubAccounts. Update bank details in Split test. --- split.go | 6 +++++- split_test.go | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/split.go b/split.go index 687ef08..5ac5672 100644 --- a/split.go +++ b/split.go @@ -119,6 +119,10 @@ func (s *SplitService) UpdateSubAccounts(splitID int, subAccountCode string, sha // For more details see https://paystack.com/docs/api/split/#remove-subaccount func (s *SplitService) RemoveSubAccount(splitID int, subAccountCode string) error { url := fmt.Sprintf("split/%d/subaccount/remove", splitID) - err := s.client.Call("POST", url, subAccountCode, nil) + split := &Split{} + requestData := map[string]string{ + "subaccount": subAccountCode, + } + err := s.client.Call("POST", url, requestData, split) return err } diff --git a/split_test.go b/split_test.go index 58c525f..26e850a 100644 --- a/split_test.go +++ b/split_test.go @@ -6,8 +6,8 @@ func TestSplitCRUD(t *testing.T) { subAccount := &SubAccount{ BusinessName: "Sunshine Studios", - SettlementBank: "044", - AccountNumber: "0193278965", + SettlementBank: "057", + AccountNumber: "0000000000", PercentageCharge: 12.8, } @@ -82,7 +82,7 @@ func TestSplitCRUD(t *testing.T) { // Test DELETE err = c.Split.RemoveSubAccount(split.SplitID, subAccount.SubAccountCode) - if err != nil || split.TotalSubAccounts != 0 { + if err != nil { t.Errorf("Failed to REMOVE Split SubAccount: %v", err) } } From 9869f5fe0aeb124b6c71a2e3fd34abba2012f066 Mon Sep 17 00:00:00 2001 From: Emmanuel Ozeh Date: Thu, 22 May 2025 21:25:14 +0100 Subject: [PATCH 09/13] Add Refund service --- paystack.go | 2 ++ refund.go | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++ refund_test.go | 63 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 refund.go create mode 100644 refund_test.go diff --git a/paystack.go b/paystack.go index 90de0b4..2f42098 100644 --- a/paystack.go +++ b/paystack.go @@ -60,6 +60,7 @@ type Client struct { Bank *BankService BulkCharge *BulkChargeService Split *SplitService + Refund *RefundService LoggingEnabled bool Log Logger @@ -128,6 +129,7 @@ func NewClient(key string, httpClient *http.Client) *Client { c.Bank = (*BankService)(&c.common) c.BulkCharge = (*BulkChargeService)(&c.common) c.Split = (*SplitService)(&c.common) + c.Refund = (*RefundService)(&c.common) return c } diff --git a/refund.go b/refund.go new file mode 100644 index 0000000..80c123b --- /dev/null +++ b/refund.go @@ -0,0 +1,71 @@ +package paystack + +import "fmt" + +type RefundService service + +type Refund struct { + Transaction Transaction `json:"transaction,omitempty"` + Integration int `json:"integration,omitempty"` + DeductedAmount int `json:"deducted_amount,omitempty"` + Channel interface{} `json:"channel,omitempty"` // TODO: Confirm data type + MerchantNote string `json:"merchant_note,omitempty"` + CustomerNote string `json:"customer_note,omitempty"` + Status string `json:"status,omitempty"` + RefundedBy string `json:"refunded_by,omitempty"` + ExpectedAt string `json:"expected_at,omitempty"` + Currency string `json:"currency,omitempty"` + Domain string `json:"domain,omitempty"` + Amount int `json:"amount,omitempty"` + FullyDeducted bool `json:"fully_deducted,omitempty"` + Id int `json:"id,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type RefundRequest struct { + Transaction string `json:"transaction,omitempty"` // Transaction reference or id + Amount int `json:"amount,omitempty"` // Optional: Defaults to original transaction amount + Currency string `json:"currency,omitempty"` // Optional + CustomerNote string `json:"customer_note,omitempty"` // Optional + MerchantNote string `json:"merchant_note,omitempty"` // Optional +} + +// RefundList is a list object for Splits. +type RefundList struct { + Meta ListMeta + Values []Refund `json:"data"` +} + +// Create and manage transaction refunds. +// For more details see https://paystack.com/docs/api/refund/#refunds +func (s *RefundService) CreateRefund(request *RefundRequest) (*Refund, error) { + url := "/refund" + refund := &Refund{} + err := s.client.Call("POST", url, request, refund) + return refund, err +} + +// List refunds available on your integration +// For more details see https://paystack.com/docs/api/refund/#list +func (s *RefundService) List() (*RefundList, error) { + return s.ListN(10, 1) +} + +// List refunds available on your integration +// For more details see https://paystack.com/docs/api/refund/#list +func (s *RefundService) ListN(count, offset int) (*RefundList, error) { + url := paginateURL("/refund", count, offset) + refunds := &RefundList{} + err := s.client.Call("GET", url, nil, refunds) + return refunds, err +} + +// Get details of a refund on your integration +// For more details see https://paystack.com/docs/api/refund/#fetch +func (s *RefundService) Get(id int) (*Refund, error) { + url := fmt.Sprintf("/refund/%d", id) + refund := &Refund{} + err := s.client.Call("GET", url, nil, refund) + return refund, err +} diff --git a/refund_test.go b/refund_test.go new file mode 100644 index 0000000..c22e8dc --- /dev/null +++ b/refund_test.go @@ -0,0 +1,63 @@ +package paystack + +import ( + "fmt" + "testing" +) + +func TestRefund(t *testing.T) { + txn := &TransactionRequest{ + Email: "user123@gmail.com", + Amount: 600000, + Reference: "Txn-" + fmt.Sprintf("%d", makeTimestamp()), + } + resp, err := c.Transaction.Initialize(txn) + if err != nil { + t.Error(err) + } + + if resp["reference"] == "" { + t.Error("Missing transaction reference") + } + + txn1, err := c.Transaction.Verify(resp["reference"].(string)) + + if err != nil { + t.Error(err) + } + + if txn1.Reference == "" { + t.Errorf("Missing transaction reference") + } + + request := &RefundRequest{ + Transaction: txn1.Reference, + } + + refund, err := c.Refund.CreateRefund(request) + if err != nil { + t.Errorf("CREATE Refund returned error: %v", err) + } + + if refund.Id == 0 { + t.Errorf("Expected Refund ID to be set") + } + + if refund.Amount != int(txn1.Amount) { + t.Errorf("Expected refund amount to be %v, got %v", txn1.Amount, refund.Amount) + } + + sameRefund, err := c.Refund.Get(refund.Id) + if err != nil { + t.Errorf("GET Refund returned error: %v", err) + } + + if sameRefund.Id != refund.Id { + t.Errorf("Expected Refund Id to be %v, got %v", refund.Id, sameRefund.Id) + } + + refunds, err := c.Refund.List() + if err != nil || !(len(refunds.Values) > 0) || !(refunds.Meta.Total > 0) { + t.Errorf("Expected refund list, got %d, returned error %v", len(refunds.Values), err) + } +} From c9d674c351a47bdd9ca97935b06a92b984a43578 Mon Sep 17 00:00:00 2001 From: Emmanuel Ozeh Date: Fri, 23 May 2025 22:28:12 +0100 Subject: [PATCH 10/13] Add Dispute service --- dispute.go | 188 ++++++++++++++++++++++++++++++++++++++++++++++++ dispute_test.go | 94 ++++++++++++++++++++++++ paystack.go | 2 + 3 files changed, 284 insertions(+) create mode 100644 dispute.go create mode 100644 dispute_test.go diff --git a/dispute.go b/dispute.go new file mode 100644 index 0000000..ca389f6 --- /dev/null +++ b/dispute.go @@ -0,0 +1,188 @@ +package paystack + +import ( + "fmt" + "time" +) + +type DisputeService service + +type DisputeMessage struct { + Sender string `json:"sender,omitempty"` + Body string `json:"body,omitempty"` + Dispute int `json:"dispute,omitempty"` + Id int `json:"id,omitempty"` + IsDeleted int `json:"is_deleted,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type DisputeState struct { + Id int `json:"id,omitempty"` + Dispute int `json:"dispute,omitempty"` + Status string `json:"status,omitempty"` + By string `json:"by,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type Dispute struct { + Currency string `json:"currency,omitempty"` + Last4 string `json:"last4,omitempty"` + Bin string `json:"bin,omitempty"` // Verify data type + TransactionReference string `json:"transaction_reference,omitempty"` + MerchantTransactionRef string `json:"merchant_transaction_reference,omitempty"` + RefundAmount int `json:"refund_amount,omitempty"` + Status string `json:"status,omitempty"` + Domain string `json:"domain,omitempty"` + Resolution string `json:"resolution,omitempty"` + Category string `json:"category,omitempty"` + Note string `json:"note,omitempty"` + Attachments interface{} `json:"attachments,omitempty"` // Verify data type + Id int `json:"id,omitempty"` + Integration int `json:"integration,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + Evidence DisputeEvidence `json:"evidence,omitempty"` + ResolvedAt string `json:"resolvedAt,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + DueAt string `json:"dueAt,omitempty"` + Transaction Transaction `json:"transaction,omitempty"` + Messages []DisputeMessage `json:"messages,omitempty"` + History []DisputeState `json:"history,omitempty"` +} + +// DisputeList is a list object for disputes. +type DisputeList struct { + Meta ListMeta + Values []Dispute `json:"data"` +} + +type DisputeEvidence struct { + CustomerEmail string `json:"customer_email,omitempty"` + CustomerName string `json:"customer_name,omitempty"` + CustomerPhone string `json:"customer_phone,omitempty"` + ServiceDetails string `json:"service_details,omitempty"` + DeliveryAddress string `json:"delivery_address,omitempty"` + Dispute int `json:"dispute,omitempty"` // Dispute ID + Id int `json:"id,omitempty"` // Evidence ID + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type UpdateDisputeRequest struct { + RefundAmount int `json:"refund_amount,omitempty"` + UploadedFilename string `json:"uploaded_filename,omitempty"` // Optional +} + +type AddDisputeEvidenceRequest struct { + CustomerEmail string `json:"customer_email,omitempty"` + CustomerName string `json:"customer_name,omitempty"` + CustomerPhone string `json:"customer_phone,omitempty"` + ServiceDetails string `json:"service_details,omitempty"` + DeliveryAddress string `json:"delivery_address,omitempty"` +} + +type ResolveDisputeRequest struct { + Resolution string `json:"resolution,omitempty"` + Message string `json:"message,omitempty"` + UploadedFilename string `json:"uploaded_filename,omitempty"` + RefundAmount int `json:"refund_amount,omitempty"` + Evidence int `json:"evidence,omitempty"` // Evidence id +} + +// All fields are optional +type DisputeFilterOptions struct { + From time.Time `json:"from,omitempty"` + To time.Time `json:"to,omitempty"` + Transaction string `json:"transaction,omitempty"` // Transaction ID + Status string `json:"status,omitempty"` // Filter dispute by status +} + +type Upload struct { + SignedUrl string `json:"signedUrl,omitempty"` + FileName string `json:"filename,omitempty"` +} + +type Export struct { + Path string `json:"path,omitempty"` + ExpiresAt string `json:"expiresAt,omitempty"` +} + +// List disputes filed against you. +// For more details see https://paystack.com/docs/api/dispute/#list +func (s *DisputeService) List(options *DisputeFilterOptions) (*DisputeList, error) { + return s.ListN(options, 10, 1) +} + +// List disputes filed against you. +// For more details see https://paystack.com/docs/api/dispute/#list +func (s *DisputeService) ListN(options *DisputeFilterOptions, count, offset int) (*DisputeList, error) { + url := paginateURL("/dispute", count, offset) + disputes := &DisputeList{} + err := s.client.Call("GET", url, options, disputes) + return disputes, err +} + +// Get details of Dispute with the specified id. +// For more details see https://paystack.com/docs/api/dispute/#fetch +func (s *DisputeService) Get(id int) (*Dispute, error) { + url := fmt.Sprintf("/dispute/%d", id) + dispute := &Dispute{} + err := s.client.Call("GET", url, nil, dispute) + return dispute, err +} + +// Retrieve disputes for a particular transaction. +// For more details see https://paystack.com/docs/api/dispute/#transaction +func (s *DisputeService) ListTransactionDisputes(id int) (*Dispute, error) { + url := fmt.Sprintf("/dispute/transaction/%d", id) + dispute := &Dispute{} + err := s.client.Call("GET", url, nil, dispute) + return dispute, err +} + +// Update details of a dispute on your integration. +// For more details see https://paystack.com/docs/api/dispute/#update +func (s *DisputeService) Update(id int, request *UpdateDisputeRequest) (*Dispute, error) { + url := fmt.Sprintf("dispute/%d", id) + dispute := &Dispute{} + err := s.client.Call("PUT", url, request, dispute) + return dispute, err +} + +// Provide evidence for a dispute. +// For more details see https://paystack.com/docs/api/dispute/#evidence +func (s *DisputeService) AddDisputeEvidence(id int, request *AddDisputeEvidenceRequest) (*DisputeEvidence, error) { + url := fmt.Sprintf("dispute/%d/evidence", id) + evidence := &DisputeEvidence{} + err := s.client.Call("POST", url, request, evidence) + return evidence, err +} + +// Resolve a dispute on your integration. +// For more details see https://paystack.com/docs/api/dispute/#resolve +func (s *DisputeService) ResolveDispute(id int, request *ResolveDisputeRequest) (*Dispute, error) { + url := fmt.Sprintf("dispute/%d/resolve", id) + dispute := &Dispute{} + err := s.client.Call("PUT", url, request, dispute) + return dispute, err +} + +// Retrieve signed upload URL for dispute evidence documents. +// For more details see https://paystack.com/docs/api/dispute/#upload-url +func (s *DisputeService) GetUploadURL(id int, uploadFilename string) (*Upload, error) { + url := fmt.Sprintf("dispute/:%d/upload_url?upload_filename=%s", id, uploadFilename) + upload := &Upload{} + err := s.client.Call("GET", url, nil, upload) + return upload, err +} + +// Export disputes available on your integration. +// For more details see https://paystack.com/docs/api/dispute/#export +func (s *DisputeService) Export(options *DisputeFilterOptions) (*Export, error) { + url := "dispute/export" + export := &Export{} + err := s.client.Call("GET", url, options, export) + return export, err +} diff --git a/dispute_test.go b/dispute_test.go new file mode 100644 index 0000000..009c820 --- /dev/null +++ b/dispute_test.go @@ -0,0 +1,94 @@ +package paystack + +import "testing" + +func TestDisputeService(t *testing.T) { + // retrieve the dispute list + options := &DisputeFilterOptions{} + disputes, err := c.Dispute.List(options) + if err != nil { + t.Errorf("Error occurred while retrieving disputes: %v", err) + } + + if !(len(disputes.Values) > 0) || !(disputes.Meta.Total > 0) { + t.Skip("You currently have no disputes on your integration") + } + + // fetch the split + dispute1, err := c.Dispute.Get(disputes.Values[0].Id) + if err != nil { + t.Errorf("GET Dispute returned error: %v", err) + } + + if dispute1.TransactionReference == "" { + t.Error("Expected Dispute Transaction Reference to be set") + } + + // list transaction disputes + _, err = c.Dispute.ListTransactionDisputes(dispute1.Transaction.ID) + if err != nil { + t.Errorf("Failed to GET Dispute by transaction ID: %v", err) + } + + // Test UPDATE Dispute + newRefundAmount := 500000 + update := &UpdateDisputeRequest{ + RefundAmount: newRefundAmount, + } + updatedDispute, err := c.Dispute.Update(dispute1.Id, update) + if err != nil { + t.Errorf("Failed to UPDATE Dispute: %v", err) + } + if updatedDispute.RefundAmount != newRefundAmount { + t.Errorf("Expected updated refund amount to be %v, got %v", newRefundAmount, updatedDispute.RefundAmount) + } + + // Test AddDisputeEvidence + evidence := &AddDisputeEvidenceRequest{ + CustomerEmail: "cus@gmail.com", + CustomerName: "Mensah King", + CustomerPhone: "0802345167", + ServiceDetails: "claim for buying product", + DeliveryAddress: "3a ladoke street ogbomoso", + } + disputeEvidence, err := c.Dispute.AddDisputeEvidence(dispute1.Id, evidence) + if err != nil { + t.Errorf("Unable to add Dispute evidence: %v", err) + } + if disputeEvidence.CustomerName == "" { + t.Error("Expected Customer Name for dispute evidence to be set") + } + + // Test GET UPLOAD URL + upload, err := c.Dispute.GetUploadURL(dispute1.Id, "receipt.pdf") + if err != nil { + t.Errorf("Unable to get upload URL: %v", err) + } + if upload.SignedUrl == "" { + t.Error("Expected Signed URL to be set") + } + + // Test Export + export, err := c.Dispute.Export(options) + if err != nil { + t.Errorf("Unable to export disputes: %v", err) + } + if export.Path == "" { + t.Error("Expected export path to be set") + } + + // Test ResolveDispute + request := &ResolveDisputeRequest{ + Resolution: "merchant-accepted", + Message: "Merchant accepted", + UploadedFilename: "qesp8a4df1xejihd9x5q", + RefundAmount: 300000, + } + resolvedDispute, err := c.Dispute.ResolveDispute(dispute1.Id, request) + if err != nil { + t.Errorf("Unable to resolve dispute: %v", err) + } + if resolvedDispute.Resolution != request.Resolution { + t.Errorf("Expected dispute resolution to be %v, got %v", request.Resolution, resolvedDispute.Resolution) + } +} diff --git a/paystack.go b/paystack.go index 2f42098..3484761 100644 --- a/paystack.go +++ b/paystack.go @@ -61,6 +61,7 @@ type Client struct { BulkCharge *BulkChargeService Split *SplitService Refund *RefundService + Dispute *DisputeService LoggingEnabled bool Log Logger @@ -130,6 +131,7 @@ func NewClient(key string, httpClient *http.Client) *Client { c.BulkCharge = (*BulkChargeService)(&c.common) c.Split = (*SplitService)(&c.common) c.Refund = (*RefundService)(&c.common) + c.Dispute = (*DisputeService)(&c.common) return c } From 53fdb5ac47cb9b4ef28316bacf98dfc33b4b2cb5 Mon Sep 17 00:00:00 2001 From: Emmanuel Ozeh Date: Thu, 29 May 2025 14:26:45 +0100 Subject: [PATCH 11/13] Add Dedicated Virtual Accounts service --- dedicated_virtual_account.go | 182 +++++++++++++++++++++++++++++++++++ dva_test.go | 130 +++++++++++++++++++++++++ paystack.go | 30 +++--- 3 files changed, 328 insertions(+), 14 deletions(-) create mode 100644 dedicated_virtual_account.go create mode 100644 dva_test.go diff --git a/dedicated_virtual_account.go b/dedicated_virtual_account.go new file mode 100644 index 0000000..dcd1028 --- /dev/null +++ b/dedicated_virtual_account.go @@ -0,0 +1,182 @@ +package paystack + +import ( + "fmt" + "net/url" +) + +type DedicatedVirtualAccountService service + +type DedicatedVirtualAccount struct { + Bank Bank `json:"bank,omitempty"` + AccountName string `json:"account_name,omitempty"` + AccountNumber string `json:"account_number,omitempty"` + Assigned bool `json:"assigned,omitempty"` + Currency string `json:"currency,omitempty"` + Metadata interface{} `json:"metadata,omitempty"` + Active bool `json:"active,omitempty"` + Id int `json:"id,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Assignment Assignment `json:"assignment,omitempty"` + Customer Customer `json:"customer,omitempty"` + SplitConfig Split `json:"split_config,omitempty"` +} + +type Assignment struct { + Integration int `json:"integration,omitempty"` + AssigneeId int `json:"assignee_id,omitempty"` + AssigneeType string `json:"assignee_type,omitempty"` + Expired bool `json:"expired,omitempty"` + AccountType string `json:"account_type,omitempty"` + AssignedAt string `json:"assigned_at,omitempty"` +} + +type DedicatedVirtualAccountRequest struct { + Customer int `json:"customer,omitempty"` // Customer ID + PreferredBank string `json:"preferred_bank,omitempty"` // Optional: We currently support Wema Bank and Titan Paystack. + SubAccount string `json:"subaccount,omitempty"` // Optional + SplitCode string `json:"split_code,omitempty"` // Optional + FirstName string `json:"first_name,omitempty"` // Optional + LastName string `json:"last_name,omitempty"` // Optional + Phone string `json:"phone,omitempty"` // Optional +} + +type AssignDVARequest struct { + Email string `json:"email,omitempty"` + FirstName string `json:"first_name,omitempty"` + MiddleName string `json:"middle_name,omitempty"` + LastName string `json:"last_name,omitempty"` + Phone string `json:"phone,omitempty"` + PreferredBank string `json:"preferred_bank,omitempty"` + Country string `json:"country,omitempty"` + SubAccount string `json:"subaccount,omitempty"` // Optional + SplitCode string `json:"split_code,omitempty"` // Optional + AccountNumber string `json:"account_number,omitempty"` + Bvn string `json:"bvn,omitempty"` + BankCode string `json:"bank_code,omitempty"` +} + +// DVAList is a list object for Dedicated Virtual Accounts. +type DVAList struct { + Meta ListMeta + Values []DedicatedVirtualAccount `json:"data"` +} + +// Filter for retrieving DVA list. All fields are optional +type DVAListFilter struct { + Active bool `json:"active,omitempty"` + Currency string `json:"currency,omitempty"` + ProviderSlug string `json:"provider_slug,omitempty"` + BankId string `json:"bank_id,omitempty"` + Customer string `json:"customer,omitempty"` +} + +type RequeryDVARequest struct { + AccountNumber string `json:"account_number,omitempty"` + ProviderSlug string `json:"provider_slug,omitempty"` + Date string `json:"date,omitempty"` // Optional +} + +type DVATransactionSplitRequest struct { + Customer int `json:"customer,omitempty"` // Customer ID or code + SubAccount string `json:"subaccount,omitempty"` // Subaccount code of the account you want to split the transaction with + SplitCode string `json:"split_code,omitempty"` // Split code consisting of the lists of accounts you want to split the transaction with + PreferredBank string `json:"preferred_bank,omitempty"` +} + +type BankProvider struct { + ProviderSlug string `json:"provider_slug,omitempty"` + BankId int `json:"bank_id,omitempty"` + BankName string `json:"bank_name,omitempty"` + Id int `json:"id,omitempty"` +} + +// Create a dedicated virtual account for an existing customer. +// For more details see https://paystack.com/docs/api/dedicated-virtual-account/#create +func (s *DedicatedVirtualAccountService) Create(request *DedicatedVirtualAccountRequest) (*DedicatedVirtualAccount, error) { + url := "/dedicated_account" + dva := &DedicatedVirtualAccount{} + err := s.client.Call("POST", url, request, dva) + return dva, err +} + +// Create a customer, validate the customer, and assign a DVA to the customer. The process is asynchronous - listen for response using webhooks. +// For more details see https://paystack.com/docs/api/dedicated-virtual-account/#assign +func (s *DedicatedVirtualAccountService) Assign(request *AssignDVARequest) (*DedicatedVirtualAccount, error) { + url := "/dedicated_account" + dva := &DedicatedVirtualAccount{} + err := s.client.Call("POST", url, request, dva) + return dva, err +} + +// List dedicated virtual accounts available on your integration. +// For more details see https://paystack.com/docs/api/dedicated-virtual-account/#list +func (s *DedicatedVirtualAccountService) List(filter *DVAListFilter) (*DVAList, error) { + return s.ListN(filter, 10, 1) +} + +// List dedicated virtual accounts available on your integration. +// For more details see https://paystack.com/docs/api/dedicated-virtual-account/#list +func (s *DedicatedVirtualAccountService) ListN(filter *DVAListFilter, count, offset int) (*DVAList, error) { + url := paginateURL("/dedicated_account", count, offset) + dvaList := &DVAList{} + err := s.client.Call("GET", url, nil, dvaList) + return dvaList, err +} + +// Get details of a dedicated virtual account on your integration. +// For more details see https://paystack.com/docs/api/dedicated-virtual-account/#fetch +func (s *DedicatedVirtualAccountService) Get(id int) (*DedicatedVirtualAccount, error) { + url := fmt.Sprintf("/dedicated_account/%d", id) + dva := &DedicatedVirtualAccount{} + err := s.client.Call("GET", url, nil, dva) + return dva, err +} + +// Requery Dedicated Virtual Account for new transactions. +// For more details see https://paystack.com/docs/api/dedicated-virtual-account/#requery +func (s *DedicatedVirtualAccountService) Requery(request *RequeryDVARequest) (*DedicatedVirtualAccount, error) { + url := fmt.Sprintf("/dedicated_account/requery?account_number=%s&provider_slug=%s&date=%s", request.AccountNumber, request.ProviderSlug, request.Date) + dva := &DedicatedVirtualAccount{} + err := s.client.Call("GET", url, nil, dva) + return dva, err +} + +// Deactivate a dedicated virtual account on your integration. +// For more details see https://paystack.com/docs/api/dedicated-virtual-account/#deactivate +func (s *DedicatedVirtualAccountService) Deactivate(id int) (*DedicatedVirtualAccount, error) { + url := fmt.Sprintf("/dedicated_account/:%d", id) + dva := &DedicatedVirtualAccount{} + err := s.client.Call("DELETE", url, nil, dva) + return dva, err +} + +// Split a dedicated virtual account transaction with one or more accounts. +// For more details see https://paystack.com/docs/api/dedicated-virtual-account/#add-split +func (s *DedicatedVirtualAccountService) Split(request *DVATransactionSplitRequest) (*DedicatedVirtualAccount, error) { + url := "/dedicated_account" + dva := &DedicatedVirtualAccount{} + err := s.client.Call("POST", url, request, dva) + return dva, err +} + +// Remove split payments for transactions on a dedicated virtual account +// For more details see https://paystack.com/docs/api/dedicated-virtual-account/#remove-split +func (s *DedicatedVirtualAccountService) RemoveSplit(acct string) (*DedicatedVirtualAccount, error) { + u := "/dedicated_account/split" + dva := &DedicatedVirtualAccount{} + req := url.Values{} + req.Add("account_number", acct) + err := s.client.Call("DELETE", u, req, dva) + return dva, err +} + +// Get available bank providers for a dedicated virtual account +// For more details see https://paystack.com/docs/api/dedicated-virtual-account/#providers +func (s *DedicatedVirtualAccountService) GetBankProviders() ([]BankProvider, error) { + url := "/dedicated_account/available_providers" + providers := []BankProvider{} + err := s.client.Call("GET", url, nil, providers) + return providers, err +} diff --git a/dva_test.go b/dva_test.go new file mode 100644 index 0000000..cc38f42 --- /dev/null +++ b/dva_test.go @@ -0,0 +1,130 @@ +package paystack + +import "testing" + +func TestDedicatedVirtualAccount(t *testing.T) { + cust := &Customer{ + FirstName: "User123", + LastName: "AdminUser", + Email: "user1-deny@gmail.com", + Phone: "+2341000000000000", + } + customer1, _ := c.Customer.Create(cust) + + // Test CREATE + dvaRequest := &DedicatedVirtualAccountRequest{ + Customer: customer1.ID, + PreferredBank: "test-bank", + } + + dva, err := c.DedicatedVirtualAccount.Create(dvaRequest) + if err != nil { + t.Errorf("CREATE Dedicated Virtual Account returned error: %v", err) + } + + if dva.AccountName == "" { + t.Errorf("Expected account name to be set") + } + + if dva.Bank.Name == "" { + t.Errorf("Expected Bank name to be set") + } + + // test ASSIGN + assignDVARequest := &AssignDVARequest{ + Email: "janedoe@test.com", + FirstName: "Jane", + MiddleName: "Karen", + LastName: "Doe", + Phone: "+2348100000000", + PreferredBank: "test-bank", + Country: "NG", + } + + dva1, err := c.DedicatedVirtualAccount.Assign(assignDVARequest) + if err != nil { + t.Errorf("ASSIGN Dedicated Virtual Account returned error: %v", err) + } + + if dva1.AccountNumber == "" { + t.Errorf("Expected account name to be set") + } + + if dva1.Bank.Name == "" { + t.Errorf("Expected Bank name to be set") + } + + // Test LIST DVA + filter := &DVAListFilter{} + dvaList, err := c.DedicatedVirtualAccount.List(filter) + if err != nil || !(len(dvaList.Values) > 0) || !(dvaList.Meta.Total > 0) { + t.Errorf("Expected DVA list, got %d, returned error %v", len(dvaList.Values), err) + } + + if dvaList.Values[0].AccountName == "" { + t.Errorf("Expected Account name for first DVA in List to be set") + } + + // Test FETCH + sameDVA, err := c.DedicatedVirtualAccount.Get(dva1.Id) + if err != nil { + t.Errorf("GET DVA returned error: %v", err) + } + + if sameDVA.AccountName != dva1.AccountName { + t.Errorf("Expected Account Name to be %v, got %v", dva1.AccountName, sameDVA.AccountName) + } + + // Test REQUERY + req := &RequeryDVARequest{ + AccountNumber: "1234567890", + ProviderSlug: "example-provider", + Date: "2023-05-30", + } + _, err = c.DedicatedVirtualAccount.Requery(req) + if err != nil { + t.Errorf("REQUERY DVA returned error: %v", err) + } + + // Test SPLIT + splitRequest := &DVATransactionSplitRequest{ + Customer: 481193, + PreferredBank: "wema-bank", + SplitCode: "SPL_e7jnRLtzla", + } + dva2, err := c.DedicatedVirtualAccount.Split(splitRequest) + if err != nil { + t.Errorf("Failed to add Split to DVA: %v", err) + } + if dva2.SplitConfig.SplitCode == "" { + t.Errorf("Expected Split Code to be set") + } + + // Test REMOVE SPLIT + noSplitDva2, err := c.DedicatedVirtualAccount.RemoveSplit(dva2.AccountNumber) + if err != nil { + t.Errorf("Failed to Remove Split from DVA: %v", err) + } + if noSplitDva2.SplitConfig.SplitCode != "" { + t.Errorf("Expected Split Code to be removed") + } + + + // Test DEACTIVATE + deactivatedDVA, err := c.DedicatedVirtualAccount.Deactivate(dva2.Id) + if err != nil { + t.Errorf("Failed to DEACTIVATE DVA: %v", err) + } + if deactivatedDVA.Assigned != false { + t.Errorf("Expected DVA to be unassigned") + } + + // Test DELETE + providers, err := c.DedicatedVirtualAccount.GetBankProviders() + if err != nil { + t.Errorf("Failed to FETCH Bank providers: %v", err) + } + if providers[0].BankName == "" { + t.Errorf("Expected Bank Name to be set for provider") + } +} diff --git a/paystack.go b/paystack.go index 3484761..9973a5d 100644 --- a/paystack.go +++ b/paystack.go @@ -48,20 +48,21 @@ type Client struct { logger Logger // Services supported by the Paystack API. // Miscellaneous actions are directly implemented on the Client object - Customer *CustomerService - Transaction *TransactionService - SubAccount *SubAccountService - Plan *PlanService - Subscription *SubscriptionService - Page *PageService - Settlement *SettlementService - Transfer *TransferService - Charge *ChargeService - Bank *BankService - BulkCharge *BulkChargeService - Split *SplitService - Refund *RefundService - Dispute *DisputeService + Customer *CustomerService + Transaction *TransactionService + SubAccount *SubAccountService + Plan *PlanService + Subscription *SubscriptionService + Page *PageService + Settlement *SettlementService + Transfer *TransferService + Charge *ChargeService + Bank *BankService + BulkCharge *BulkChargeService + Split *SplitService + Refund *RefundService + Dispute *DisputeService + DedicatedVirtualAccount *DedicatedVirtualAccountService LoggingEnabled bool Log Logger @@ -132,6 +133,7 @@ func NewClient(key string, httpClient *http.Client) *Client { c.Split = (*SplitService)(&c.common) c.Refund = (*RefundService)(&c.common) c.Dispute = (*DisputeService)(&c.common) + c.DedicatedVirtualAccount = (*DedicatedVirtualAccountService)(&c.common) return c } From e635930b198a170203042ef166bb0cd94ac9eba8 Mon Sep 17 00:00:00 2001 From: Emmanuel Ozeh Date: Sun, 1 Jun 2025 18:48:16 +0100 Subject: [PATCH 12/13] Add Product service --- paystack.go | 2 ++ product.go | 88 +++++++++++++++++++++++++++++++++++++++++++++++++ product_test.go | 72 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 product.go create mode 100644 product_test.go diff --git a/paystack.go b/paystack.go index 9973a5d..1a254eb 100644 --- a/paystack.go +++ b/paystack.go @@ -63,6 +63,7 @@ type Client struct { Refund *RefundService Dispute *DisputeService DedicatedVirtualAccount *DedicatedVirtualAccountService + Product *ProductService LoggingEnabled bool Log Logger @@ -134,6 +135,7 @@ func NewClient(key string, httpClient *http.Client) *Client { c.Refund = (*RefundService)(&c.common) c.Dispute = (*DisputeService)(&c.common) c.DedicatedVirtualAccount = (*DedicatedVirtualAccountService)(&c.common) + c.Product = (*ProductService)(&c.common) return c } diff --git a/product.go b/product.go new file mode 100644 index 0000000..427008c --- /dev/null +++ b/product.go @@ -0,0 +1,88 @@ +package paystack + +import "fmt" + +type ProductService service + +type Product struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Currency string `json:"currency,omitempty"` + Price int `json:"price,omitempty"` + Quantity int `json:"quantity,omitempty"` + IsShippable bool `json:"is_shippable,omitempty"` + Unlimited bool `json:"unlimited,omitempty"` + Integration int `json:"integration,omitempty"` + Domain string `json:"domain,omitempty"` + Metadata interface{} `json:"metadata,omitempty"` + Slug string `json:"slug,omitempty"` + ProductCode string `json:"product_code,omitempty"` + QuantitySold int `json:"quantity_sold,omitempty"` + Type string `json:"type,omitempty"` + ShippingFields interface{} `json:"shipping_fields,omitempty"` + Active bool `json:"active,omitempty"` + InStock bool `json:"in_stock,omitempty"` + Minimum_orderable int `json:"minimum_orderable,omitempty"` + MaximumOrderable int `json:"maximum_orderable,omitempty"` + LowStockAlert bool `json:"low_stock_alert,omitempty"` + Id int `json:"id,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type ProductRequest struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Price string `json:"price,omitempty"` + Currency string `json:"currency,omitempty"` + Unlimited bool `json:"unlimited,omitempty"` // Optional + Quantity int `json:"quantity,omitempty"` // Optional +} + +// ProductList is a list object for Products. +type ProductList struct { + Meta ListMeta + Values []Product `json:"data"` +} + +// Create a product on your integration +// For more details see https://paystack.com/docs/api/product/#create +func (s *ProductService) Create(request *ProductRequest) (*Product, error) { + u := "/product" + product := &Product{} + err := s.client.Call("POST", u, request, product) + return product, err +} + +// List returns a list of Products. +// For more details see https://paystack.com/docs/api/product/#list +func (s *ProductService) List() (*ProductList, error) { + return s.ListN(10, 1) +} + +// ListN returns a list of Products +// For more details see https://paystack.com/docs/api/product/#list +func (s *ProductService) ListN(count, offset int) (*ProductList, error) { + u := paginateURL("/product", count, offset) + products := &ProductList{} + err := s.client.Call("GET", u, nil, products) + return products, err +} + +// Get details of Product with the specified id +// For more details see https://paystack.com/docs/api/product/#fetch +func (s *ProductService) Get(id int) (*Product, error) { + url := fmt.Sprintf("/product/%d", id) + product := &Product{} + err := s.client.Call("GET", url, nil, product) + return product, err +} + +// Update details of a Product on your integration +// For more details see https://paystack.com/docs/api/product/#update +func (s *ProductService) Update(id int, request *ProductRequest) (*Product, error) { + url := fmt.Sprintf("product/%d", id) + product := &Product{} + err := s.client.Call("PUT", url, request, product) + return product, err +} diff --git a/product_test.go b/product_test.go new file mode 100644 index 0000000..e74abeb --- /dev/null +++ b/product_test.go @@ -0,0 +1,72 @@ +package paystack + +import ( + "strconv" + "testing" +) + +func TestProductCRUD(t *testing.T) { + + productRequest := &ProductRequest{ + Name: "Puff Puff", + Description: "Crispy flour ball with fluffy interior", + Price: "5000", + Currency: "NGN", + Unlimited: false, + Quantity: 100, + } + + // Test CREATE + product, err := c.Product.Create(productRequest) + if err != nil { + t.Errorf("CREATE Product returned error: %v", err) + } + + if product.ProductCode == "" { + t.Errorf("Expected Product Code to be set") + } + + if product.Name != productRequest.Name { + t.Errorf("Expected Product name to be %v, got %v", productRequest.Name, product.Name) + } + + // Test FETCH + sameProduct, err := c.Product.Get(product.Id) + if err != nil { + t.Errorf("GET Product returned error: %v", err) + } + + if sameProduct.Name != product.Name { + t.Errorf("Expected Product Name to be %v, got %v", product.Name, sameProduct.Name) + } + + if sameProduct.ProductCode != product.ProductCode { + t.Errorf("Expected Product Code to be %v, got %v", product.ProductCode, sameProduct.ProductCode) + } + + // retrieve the Product list + products, err := c.Product.List() + if err != nil || !(len(products.Values) > 0) || !(products.Meta.Total > 0) { + t.Errorf("Expected Product list, got %d, returned error %v", len(products.Values), err) + } + + // Test UPDATE Product + updateRequest := &ProductRequest{ + Name: "Puff Puff", + Description: "Crispy flour ball with fluffy interior", + Price: "7000", + Currency: "NGN", + Unlimited: false, + Quantity: 170, + } + updatedProduct, err := c.Product.Update(product.Id, updateRequest) + if err != nil { + t.Errorf("Failed to UPDATE Product: %v", err) + } + if updatedProduct.Quantity != updateRequest.Quantity { + t.Errorf("Expected Product Quantity to be updated to %v, got %v", updatedProduct.Quantity, updateRequest.Quantity) + } + if strconv.Itoa(updatedProduct.Price) != updateRequest.Price { + t.Errorf("Expected Product Quantity to be updated to %v, got %v", updatedProduct.Quantity, product.Quantity) + } +} From 5bbc496f7084791ab496df8120e246f710c6c2aa Mon Sep 17 00:00:00 2001 From: Emmanuel Ozeh Date: Mon, 16 Jun 2025 13:50:06 +0100 Subject: [PATCH 13/13] Add Terminal Service --- paystack.go | 2 ++ terminal.go | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 terminal.go diff --git a/paystack.go b/paystack.go index 1a254eb..d55b14e 100644 --- a/paystack.go +++ b/paystack.go @@ -64,6 +64,7 @@ type Client struct { Dispute *DisputeService DedicatedVirtualAccount *DedicatedVirtualAccountService Product *ProductService + Terminal *TerminalService LoggingEnabled bool Log Logger @@ -136,6 +137,7 @@ func NewClient(key string, httpClient *http.Client) *Client { c.Dispute = (*DisputeService)(&c.common) c.DedicatedVirtualAccount = (*DedicatedVirtualAccountService)(&c.common) c.Product = (*ProductService)(&c.common) + c.Terminal = (*TerminalService)(&c.common) return c } diff --git a/terminal.go b/terminal.go new file mode 100644 index 0000000..9797c80 --- /dev/null +++ b/terminal.go @@ -0,0 +1,67 @@ +package paystack + +import ( + "fmt" + "net/url" +) + +type TerminalService service + +type Terminal struct { + Id int `json:"id,omitempty"` + SerialNumber string `json:"serial_number,omitempty"` + DeviceMake string `json:"device_make,omitempty"` + TerminalId string `json:"terminal_id,omitempty"` + Integration int `json:"integration,omitempty"` + Domain string `json:"domain,omitempty"` + Name string `json:"name,omitempty"` + Address string `json:"address,omitempty"` + Status string `json:"status,omitempty"` +} + +// TerminalList is a list object for terminals. +type TerminalList struct { + Meta ListMeta + Values []Terminal `json:"data,omitempty"` +} + +type TerminalResponse struct { + Status bool `json:"status,omitempty"` + Message string `json:"message,omitempty"` +} + +type TerminalUpdateRequest struct { + Name string `json:"name,omitempty"` + Address string `json:"address,omitempty"` +} + +// Activate your debug device by linking it to your integration +// For more details see https://paystack.com/docs/api/terminal/#commission +func (s *TerminalService) Commission(serial string) (*TerminalResponse, error) { + u := "/terminal/commission" + response := &TerminalResponse{} + req := url.Values{} + req.Add("serial_number", serial) + err := s.client.Call("POST", u, req, response) + return response, err +} + +// Unlink your debug device from your integration +// For more details see https://paystack.com/docs/api/terminal/#decommission +func (s *TerminalService) Decommission(serial string) (*TerminalResponse, error) { + u := "/terminal/decommission" + response := &TerminalResponse{} + req := url.Values{} + req.Add("serial_number", serial) + err := s.client.Call("POST", u, req, response) + return response, err +} + +// Update the details of a Terminal +// For more details see https://paystack.com/docs/api/terminal/#update +func (s *TerminalService) Update(terminalId string, request TerminalUpdateRequest) (*TerminalResponse, error) { + u := fmt.Sprintf("/terminal/%v", terminalId) + response := &TerminalResponse{} + err := s.client.Call("PUT", u, request, response) + return response, err +}