Skip to content

Commit 1ba85a7

Browse files
committed
Move DTOne airtime service implementation into this repo
1 parent 3e35dd0 commit 1ba85a7

File tree

11 files changed

+928
-22
lines changed

11 files changed

+928
-22
lines changed

cmd/mailroom/main.go

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
_ "github.com/nyaruka/mailroom/core/tasks/interrupts"
2727
_ "github.com/nyaruka/mailroom/core/tasks/msgs"
2828
_ "github.com/nyaruka/mailroom/core/tasks/starts"
29+
_ "github.com/nyaruka/mailroom/services/airtime/dtone"
2930
_ "github.com/nyaruka/mailroom/services/ivr/bandwidth"
3031
_ "github.com/nyaruka/mailroom/services/ivr/twiml"
3132
_ "github.com/nyaruka/mailroom/services/ivr/vonage"

core/models/org.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ import (
2121
"github.com/nyaruka/goflow/envs"
2222
"github.com/nyaruka/goflow/flows"
2323
"github.com/nyaruka/goflow/flows/engine"
24-
"github.com/nyaruka/goflow/services/airtime/dtone"
2524
"github.com/nyaruka/goflow/services/email/smtp"
2625
"github.com/nyaruka/goflow/utils"
2726
"github.com/nyaruka/goflow/utils/smtpx"
2827
"github.com/nyaruka/mailroom/core/goflow"
2928
"github.com/nyaruka/mailroom/runtime"
29+
"github.com/nyaruka/mailroom/services/airtime/dtone"
3030
"github.com/nyaruka/null/v3"
3131
)
3232

go.mod

+7-7
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ require (
88
github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3
99
github.com/appleboy/go-fcm v1.2.3
1010
github.com/aws/aws-sdk-go-v2 v1.36.3
11-
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.18.9
11+
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.18.10
1212
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.44.2
13-
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.42.1
13+
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.42.2
1414
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.1
1515
github.com/buger/jsonparser v1.1.1
1616
github.com/elastic/go-elasticsearch/v8 v8.17.1
17-
github.com/getsentry/sentry-go v0.31.1
17+
github.com/getsentry/sentry-go v0.32.0
1818
github.com/go-chi/chi/v5 v5.2.1
1919
github.com/go-playground/validator/v10 v10.26.0
2020
github.com/golang-jwt/jwt v3.2.2+incompatible
@@ -26,7 +26,7 @@ require (
2626
github.com/lib/pq v1.10.9
2727
github.com/nyaruka/ezconf v0.3.0
2828
github.com/nyaruka/gocommon v1.61.1
29-
github.com/nyaruka/goflow v0.236.1
29+
github.com/nyaruka/goflow v0.236.3
3030
github.com/nyaruka/null/v3 v3.0.0
3131
github.com/nyaruka/redisx v0.9.0
3232
github.com/nyaruka/rp-indexer/v10 v10.1.1
@@ -132,9 +132,9 @@ require (
132132
golang.org/x/text v0.24.0 // indirect
133133
golang.org/x/time v0.11.0 // indirect
134134
google.golang.org/appengine/v2 v2.0.6 // indirect
135-
google.golang.org/genproto v0.0.0-20250407143221-ac9807e6c755 // indirect
136-
google.golang.org/genproto/googleapis/api v0.0.0-20250407143221-ac9807e6c755 // indirect
137-
google.golang.org/genproto/googleapis/rpc v0.0.0-20250407143221-ac9807e6c755 // indirect
135+
google.golang.org/genproto v0.0.0-20250409194420-de1ac958c67a // indirect
136+
google.golang.org/genproto/googleapis/api v0.0.0-20250409194420-de1ac958c67a // indirect
137+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a // indirect
138138
google.golang.org/grpc v1.71.1 // indirect
139139
google.golang.org/protobuf v1.36.6 // indirect
140140
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect

go.sum

+14-14
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ github.com/aws/aws-sdk-go-v2/config v1.29.13 h1:RgdPqWoE8nPpIekpVpDJsBckbqT4Liia
6464
github.com/aws/aws-sdk-go-v2/config v1.29.13/go.mod h1:NI28qs/IOUIRhsR7GQ/JdexoqRN9tDxkIrYZq0SOF44=
6565
github.com/aws/aws-sdk-go-v2/credentials v1.17.66 h1:aKpEKaTy6n4CEJeYI1MNj97oSDLi4xro3UzQfwf5RWE=
6666
github.com/aws/aws-sdk-go-v2/credentials v1.17.66/go.mod h1:xQ5SusDmHb/fy55wU0QqTy0yNfLqxzec59YcsRZB+rI=
67-
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.18.9 h1:EU6VkY8G4N+IFl0D2Cd9LcUeJHyNdLJAbHfMD9v5GHQ=
68-
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.18.9/go.mod h1:JlH7zEPanxEEBLAAnKBRNZz+nrxTTMVKO40P5+umoUQ=
67+
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.18.10 h1:oM3ePE921uDbxY1FPGCBcs56ASnIGkQKWfoyR0r230w=
68+
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.18.10/go.mod h1:zrzE/286uXd7fQsTj6DF1zEDsJ05KllejnQaYLNQAUk=
6969
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw=
7070
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M=
7171
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
@@ -78,8 +78,8 @@ github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcu
7878
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
7979
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.44.2 h1:VaR7NCUhvDtn14Idz6krMY32gXtq5FtYjHqz90xyYs4=
8080
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.44.2/go.mod h1:HJlcOk+S/wjJuR/8jPa8GhnEKdKqqiQ5wjsE1PjuO1o=
81-
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.42.1 h1:67oYHlAdIoWS65kdTKatf9o1eDNkR2wan6TlBdP3oe4=
82-
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.42.1/go.mod h1:yYaWRnVSPyAmexW5t7G3TcuYoalYfT+xQwzWsvtUQ7M=
81+
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.42.2 h1:VX3BzTSxI/XGUfpw8RJCcVWMqL0iK+Kee0XaxPMyBuY=
82+
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.42.2/go.mod h1:yYaWRnVSPyAmexW5t7G3TcuYoalYfT+xQwzWsvtUQ7M=
8383
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.25.2 h1:D1Af/NlGfG2/8S3EY/hCUlvPcfu2UrX4+XaGeiFzJQM=
8484
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.25.2/go.mod h1:lUqWdw5/esjPTkITXhN4C66o1ltwDq2qQ12j3SOzhVg=
8585
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
@@ -131,8 +131,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
131131
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
132132
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
133133
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
134-
github.com/getsentry/sentry-go v0.31.1 h1:ELVc0h7gwyhnXHDouXkhqTFSO5oslsRDk0++eyE0KJ4=
135-
github.com/getsentry/sentry-go v0.31.1/go.mod h1:CYNcMMz73YigoHljQRG+qPF+eMq8gG72XcGN/p71BAY=
134+
github.com/getsentry/sentry-go v0.32.0 h1:YKs+//QmwE3DcYtfKRH8/KyOOF/I6Qnx7qYGNHCGmCY=
135+
github.com/getsentry/sentry-go v0.32.0/go.mod h1:CYNcMMz73YigoHljQRG+qPF+eMq8gG72XcGN/p71BAY=
136136
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
137137
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
138138
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
@@ -210,8 +210,8 @@ github.com/nyaruka/ezconf v0.3.0 h1:kGvJqVN8AHowb4HdaHAviJ0Z3yI5Pyekp1WqibFEaGk=
210210
github.com/nyaruka/ezconf v0.3.0/go.mod h1:89GUW6EPRNLIxT7lC4LWnjWTgZeQwRoX7lBmc8ralAU=
211211
github.com/nyaruka/gocommon v1.61.1 h1:qulMx0jHDWoDagCJbVs7CMLZA33jfa5kYutlXR66pHM=
212212
github.com/nyaruka/gocommon v1.61.1/go.mod h1:HcwpCzwt8XK7SxmGHYRF1R9viOAvlU7VtXTGuEmJx/8=
213-
github.com/nyaruka/goflow v0.236.1 h1:Gvk8ya6FKB8B8n9BMAsCcOIrGgDrMav1dm7ocRbFs7w=
214-
github.com/nyaruka/goflow v0.236.1/go.mod h1:m0bwl9WfJhBQHeabOGnWoyy6n5GXaoU27iE7X3hDkeg=
213+
github.com/nyaruka/goflow v0.236.3 h1:vZt8CZJUxUTDwPq5G5J62X703AwaiKQNfq+U8X1Knik=
214+
github.com/nyaruka/goflow v0.236.3/go.mod h1:m0bwl9WfJhBQHeabOGnWoyy6n5GXaoU27iE7X3hDkeg=
215215
github.com/nyaruka/null/v2 v2.0.3 h1:rdmMRQyVzrOF3Jff/gpU/7BDR9mQX0lcLl4yImsA3kw=
216216
github.com/nyaruka/null/v2 v2.0.3/go.mod h1:OCVeCkCXwrg5/qE6RU0c1oUVZBy+ZDrT+xYg1XSaIWA=
217217
github.com/nyaruka/null/v3 v3.0.0 h1:JvOiNuKmRBFHxzZFt4sWii+ewmMkCQ1vO7X0clTNn6E=
@@ -335,12 +335,12 @@ google.golang.org/api v0.228.0 h1:X2DJ/uoWGnY5obVjewbp8icSL5U4FzuCfy9OjbLSnLs=
335335
google.golang.org/api v0.228.0/go.mod h1:wNvRS1Pbe8r4+IfBIniV8fwCpGwTrYa+kMUDiC5z5a4=
336336
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
337337
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
338-
google.golang.org/genproto v0.0.0-20250407143221-ac9807e6c755 h1:bldQzRMfyYSvYsP0oCgOIsBryyDN2Ci7HxB+rx3L7Qw=
339-
google.golang.org/genproto v0.0.0-20250407143221-ac9807e6c755/go.mod h1:qD4k1RhYfNmRjqaHJxKLG/HRtqbXVclhjop2mPlxGwA=
340-
google.golang.org/genproto/googleapis/api v0.0.0-20250407143221-ac9807e6c755 h1:AMLTAunltONNuzWgVPZXrjLWtXpsG6A3yLLPEoJ/IjU=
341-
google.golang.org/genproto/googleapis/api v0.0.0-20250407143221-ac9807e6c755/go.mod h1:2R6XrVC8Oc08GlNh8ujEpc7HkLiEZ16QeY7FxIs20ac=
342-
google.golang.org/genproto/googleapis/rpc v0.0.0-20250407143221-ac9807e6c755 h1:TwXJCGVREgQ/cl18iY0Z4wJCTL/GmW+Um2oSwZiZPnc=
343-
google.golang.org/genproto/googleapis/rpc v0.0.0-20250407143221-ac9807e6c755/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
338+
google.golang.org/genproto v0.0.0-20250409194420-de1ac958c67a h1:AoyioNVZR+nS6zbvnvW5rjQdeQu7/BWwIT7YI8Gq5wU=
339+
google.golang.org/genproto v0.0.0-20250409194420-de1ac958c67a/go.mod h1:qD4k1RhYfNmRjqaHJxKLG/HRtqbXVclhjop2mPlxGwA=
340+
google.golang.org/genproto/googleapis/api v0.0.0-20250409194420-de1ac958c67a h1:OQ7sHVzkx6L57dQpzUS4ckfWJ51KDH74XHTDe23xWAs=
341+
google.golang.org/genproto/googleapis/api v0.0.0-20250409194420-de1ac958c67a/go.mod h1:2R6XrVC8Oc08GlNh8ujEpc7HkLiEZ16QeY7FxIs20ac=
342+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a h1:GIqLhp/cYUkuGuiT+vJk8vhOP86L4+SP5j8yXgeVpvI=
343+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
344344
google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI=
345345
google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
346346
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=

services/airtime/dtone/client.go

+218
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package dtone
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"strings"
10+
11+
"github.com/nyaruka/gocommon/httpx"
12+
"github.com/nyaruka/gocommon/jsonx"
13+
14+
"github.com/shopspring/decimal"
15+
)
16+
17+
type StatusCID int
18+
19+
const (
20+
apiURL = "https://dvs-api.dtone.com/v1/"
21+
22+
// see https://dvs-api-doc.dtone.com/#section/Overview/Transactions
23+
StatusCIDCreated StatusCID = 1
24+
StatusCIDConfirmed StatusCID = 2
25+
StatusCIDRejected StatusCID = 3
26+
StatusCIDCancelled StatusCID = 4
27+
StatusCIDSubmitted StatusCID = 5
28+
StatusCIDCompleted StatusCID = 7
29+
StatusCIDReversed StatusCID = 8
30+
StatusCIDDeclined StatusCID = 9
31+
)
32+
33+
// Client is a DTOne client, see https://dvs-api-doc.dtone.com/ for API docs
34+
type Client struct {
35+
httpClient *http.Client
36+
httpRetries *httpx.RetryConfig
37+
key string
38+
secret string
39+
}
40+
41+
// NewClient creates a new DT One client
42+
func NewClient(httpClient *http.Client, httpRetries *httpx.RetryConfig, key, secret string) *Client {
43+
return &Client{httpClient: httpClient, httpRetries: httpRetries, key: key, secret: secret}
44+
}
45+
46+
// error response contains errors when a request fails
47+
type errorResponse struct {
48+
Errors []struct {
49+
Code int `json:"code"`
50+
Message string `json:"message"`
51+
} `json:"errors"`
52+
}
53+
54+
func (e *errorResponse) Error() string {
55+
msgs := make([]string, len(e.Errors))
56+
for i := range e.Errors {
57+
msgs[i] = e.Errors[i].Message
58+
}
59+
return strings.Join(msgs, ", ")
60+
}
61+
62+
// Operator is a mobile operator
63+
type Operator struct {
64+
ID int `json:"id"`
65+
Name string `json:"name"`
66+
Country struct {
67+
Name string `json:"name"`
68+
ISOCode string `json:"iso_code"`
69+
Regions []struct {
70+
Name string `json:"name"`
71+
Code string `json:"code"`
72+
} `json:"regions"`
73+
} `json:"country"`
74+
Identified bool `json:"identified"`
75+
}
76+
77+
// LookupMobileNumber see https://dvs-api-doc.dtone.com/#tag/Mobile-Number
78+
func (c *Client) LookupMobileNumber(ctx context.Context, phoneNumber string) ([]*Operator, *httpx.Trace, error) {
79+
var response []*Operator
80+
81+
payload := &struct {
82+
MobileNumber string `json:"mobile_number"`
83+
}{
84+
MobileNumber: phoneNumber,
85+
}
86+
87+
trace, err := c.request(ctx, "POST", "lookup/mobile-number", payload, &response)
88+
if err != nil {
89+
return nil, trace, err
90+
}
91+
92+
return response, trace, nil
93+
}
94+
95+
// Product is an available digital services product
96+
type Product struct {
97+
ID int `json:"id"`
98+
Name string `json:"name"`
99+
Description string `json:"description"`
100+
Service struct {
101+
ID int `json:"id"`
102+
Name string `json:"name"`
103+
} `json:"service"`
104+
Operator struct {
105+
ID int `json:"id"`
106+
Name string `json:"name"`
107+
} `json:"operator"`
108+
Type string `json:"type"`
109+
Source struct {
110+
Amount decimal.Decimal `json:"amount"`
111+
Unit string `json:"unit"`
112+
UnitType string `json:"unit_type"`
113+
} `json:"source"`
114+
Destination struct {
115+
Amount decimal.Decimal `json:"amount"`
116+
Unit string `json:"unit"`
117+
UnitType string `json:"unit_type"`
118+
} `json:"destination"`
119+
}
120+
121+
// Products see https://dvs-api-doc.dtone.com/#tag/Products
122+
func (c *Client) Products(ctx context.Context, _type string, operatorID int) ([]*Product, *httpx.Trace, error) {
123+
var response []*Product
124+
125+
// TODO endpoint could return more than 100 products in which case we need to page
126+
127+
trace, err := c.request(ctx, "GET", fmt.Sprintf("products?type=%s&operator_id=%d&per_page=100", _type, operatorID), nil, &response)
128+
if err != nil {
129+
return nil, trace, err
130+
}
131+
132+
return response, trace, nil
133+
}
134+
135+
// Transaction is a product sent to a beneficiary
136+
type Transaction struct {
137+
ID int64 `json:"id"`
138+
ExternalID string `json:"external_id"`
139+
CreationDate string `json:"creation_date"`
140+
ConfirmationExpirationDate string `json:"confirmation_expiration_date"`
141+
ConfirmationDate string `json:"confirmation_date"`
142+
Status struct {
143+
ID int `json:"id"`
144+
Message string `json:"message"`
145+
Class struct {
146+
ID StatusCID `json:"id"`
147+
Message string `json:"message"`
148+
}
149+
} `json:"status"`
150+
}
151+
152+
// TransactionAsync see https://dvs-api-doc.dtone.com/#tag/Transactions
153+
func (c *Client) TransactionAsync(ctx context.Context, externalID string, productID int, mobileNumber string) (*Transaction, *httpx.Trace, error) {
154+
var response *Transaction
155+
156+
type creditPartyIdentifier struct {
157+
MobileNumber string `json:"mobile_number"`
158+
}
159+
160+
payload := &struct {
161+
ExternalID string `json:"external_id"`
162+
ProductID int `json:"product_id"`
163+
AutoConfirm bool `json:"auto_confirm"`
164+
CreditPartyIdentifier creditPartyIdentifier `json:"credit_party_identifier"`
165+
}{
166+
ExternalID: externalID,
167+
ProductID: productID,
168+
AutoConfirm: true,
169+
CreditPartyIdentifier: creditPartyIdentifier{
170+
MobileNumber: mobileNumber,
171+
},
172+
}
173+
174+
trace, err := c.request(ctx, "POST", "async/transactions", payload, &response)
175+
if err != nil {
176+
return nil, trace, err
177+
}
178+
179+
return response, trace, nil
180+
}
181+
182+
func (c *Client) request(ctx context.Context, method, endpoint string, payload any, response any) (*httpx.Trace, error) {
183+
url := apiURL + endpoint
184+
headers := map[string]string{}
185+
var body io.Reader
186+
187+
if payload != nil {
188+
data, err := jsonx.Marshal(payload)
189+
if err != nil {
190+
return nil, err
191+
}
192+
body = bytes.NewReader(data)
193+
headers["Content-Type"] = "application/json"
194+
}
195+
196+
req, err := httpx.NewRequest(ctx, method, url, body, headers)
197+
if err != nil {
198+
return nil, err
199+
}
200+
201+
req.SetBasicAuth(c.key, c.secret)
202+
203+
trace, err := httpx.DoTrace(c.httpClient, req, c.httpRetries, nil, -1)
204+
if err != nil {
205+
return trace, err
206+
}
207+
208+
if trace.Response.StatusCode >= 400 {
209+
response := &errorResponse{}
210+
jsonx.Unmarshal(trace.ResponseBody, response)
211+
return trace, response
212+
}
213+
214+
if response != nil {
215+
return trace, jsonx.Unmarshal(trace.ResponseBody, response)
216+
}
217+
return trace, nil
218+
}

0 commit comments

Comments
 (0)