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) 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 } 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/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/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 8c78c13..d55b14e 100644 --- a/paystack.go +++ b/paystack.go @@ -48,17 +48,23 @@ 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 + 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 + Product *ProductService + Terminal *TerminalService LoggingEnabled bool Log Logger @@ -126,6 +132,12 @@ 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) + c.Refund = (*RefundService)(&c.common) + 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/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) + } +} 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) + } +} diff --git a/split.go b/split.go new file mode 100644 index 0000000..5ac5672 --- /dev/null +++ b/split.go @@ -0,0 +1,128 @@ +package paystack + +import "fmt" + +// SplitService handles operations related to transaction Splits +// For more details see https://paystack.com/docs/api/split/ +type SplitService service + +// Represents a Paystack Split payment +type Split 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"` +} + +// 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 []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. +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"` +} + +// 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 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 := &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) + 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 new file mode 100644 index 0000000..26e850a --- /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: "057", + AccountNumber: "0000000000", + 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 { + t.Errorf("Failed to REMOVE Split SubAccount: %v", err) + } +} 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 +}