malipopay 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Gemfile +5 -0
- data/LICENSE +21 -0
- data/README.md +312 -0
- data/Rakefile +7 -0
- data/docs/configuration.md +300 -0
- data/docs/customers.md +133 -0
- data/docs/error-handling.md +274 -0
- data/docs/getting-started.md +160 -0
- data/docs/invoices.md +200 -0
- data/docs/payments.md +284 -0
- data/docs/sms.md +182 -0
- data/docs/webhooks.md +219 -0
- data/lib/malipopay/client.rb +76 -0
- data/lib/malipopay/errors.rb +49 -0
- data/lib/malipopay/http_client.rb +151 -0
- data/lib/malipopay/resources/account.rb +60 -0
- data/lib/malipopay/resources/customers.rb +60 -0
- data/lib/malipopay/resources/invoices.rb +61 -0
- data/lib/malipopay/resources/payments.rb +88 -0
- data/lib/malipopay/resources/products.rb +47 -0
- data/lib/malipopay/resources/references.rb +46 -0
- data/lib/malipopay/resources/sms.rb +32 -0
- data/lib/malipopay/resources/transactions.rb +58 -0
- data/lib/malipopay/version.rb +5 -0
- data/lib/malipopay/webhooks/verifier.rb +69 -0
- data/lib/malipopay.rb +29 -0
- data/malipopay.gemspec +42 -0
- metadata +174 -0
data/docs/invoices.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Invoices
|
|
2
|
+
|
|
3
|
+
The `invoices` resource lets you create, manage, and track invoices. Invoices can include line items with tax, and you can record payments against them as they come in.
|
|
4
|
+
|
|
5
|
+
## Create an Invoice
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
invoice = client.invoices.create(
|
|
9
|
+
customer_id: 'cust_abc123',
|
|
10
|
+
currency: 'TZS',
|
|
11
|
+
due_date: '2024-03-15',
|
|
12
|
+
items: [
|
|
13
|
+
{
|
|
14
|
+
description: 'Website Development',
|
|
15
|
+
quantity: 1,
|
|
16
|
+
unit_price: 2_500_000
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
description: 'Domain Registration (.co.tz)',
|
|
20
|
+
quantity: 1,
|
|
21
|
+
unit_price: 75_000
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
description: 'Hosting (12 months)',
|
|
25
|
+
quantity: 12,
|
|
26
|
+
unit_price: 50_000
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
notes: 'Payment due within 15 days. M-Pesa and bank transfer accepted.',
|
|
30
|
+
tax_rate: 18 # VAT at 18%
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if invoice['success']
|
|
34
|
+
puts "Invoice created: #{invoice['data']['id']}"
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
In this example, the subtotal is TZS 3,175,000 (2,500,000 + 75,000 + 600,000), and with 18% VAT the total would be TZS 3,746,500.
|
|
39
|
+
|
|
40
|
+
## Tax Calculation
|
|
41
|
+
|
|
42
|
+
MaliPoPay calculates tax automatically based on the `tax_rate` you provide:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
# Invoice with 18% VAT (Tanzania standard rate)
|
|
46
|
+
invoice = client.invoices.create(
|
|
47
|
+
customer_id: 'cust_abc123',
|
|
48
|
+
currency: 'TZS',
|
|
49
|
+
due_date: '2024-04-30',
|
|
50
|
+
items: [
|
|
51
|
+
{ description: 'Consulting (8 hours)', quantity: 8, unit_price: 150_000 }
|
|
52
|
+
],
|
|
53
|
+
tax_rate: 18
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Subtotal: TZS 1,200,000
|
|
57
|
+
# VAT (18%): TZS 216,000
|
|
58
|
+
# Total: TZS 1,416,000
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
For tax-exempt invoices, omit the `tax_rate` field or set it to `0`:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
invoice = client.invoices.create(
|
|
65
|
+
customer_id: 'cust_abc123',
|
|
66
|
+
currency: 'TZS',
|
|
67
|
+
due_date: '2024-04-30',
|
|
68
|
+
items: [
|
|
69
|
+
{ description: 'Government service (exempt)', quantity: 1, unit_price: 500_000 }
|
|
70
|
+
],
|
|
71
|
+
tax_rate: 0
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## List Invoices
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
invoices = client.invoices.list
|
|
79
|
+
|
|
80
|
+
if invoices['success']
|
|
81
|
+
invoices['data'].each do |inv|
|
|
82
|
+
puts "#{inv['id']}: TZS #{inv['total']} (#{inv['status']})"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Get an Invoice by ID
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
invoice = client.invoices.get('inv_xyz789')
|
|
91
|
+
|
|
92
|
+
if invoice['success']
|
|
93
|
+
puts "Invoice: #{invoice['data']}"
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Record a Payment Against an Invoice
|
|
98
|
+
|
|
99
|
+
When a customer makes a partial or full payment, record it against the invoice:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
# Record a partial payment
|
|
103
|
+
partial = client.invoices.record_payment(
|
|
104
|
+
invoice_id: 'inv_xyz789',
|
|
105
|
+
amount: 1_000_000,
|
|
106
|
+
reference: 'MPESA-TXN-ABC123',
|
|
107
|
+
payment_method: 'M-Pesa',
|
|
108
|
+
notes: 'Partial payment received via M-Pesa'
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Later, record the remaining balance
|
|
112
|
+
final = client.invoices.record_payment(
|
|
113
|
+
invoice_id: 'inv_xyz789',
|
|
114
|
+
amount: 2_746_500,
|
|
115
|
+
reference: 'CRDB-TXN-DEF456',
|
|
116
|
+
payment_method: 'Bank Transfer',
|
|
117
|
+
notes: 'Final payment via CRDB bank transfer'
|
|
118
|
+
)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Approve a Draft Invoice
|
|
122
|
+
|
|
123
|
+
Invoices may start as drafts. Approve them when ready to send to the customer:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
approval = client.invoices.approve_draft(invoice_id: 'inv_draft_001')
|
|
127
|
+
|
|
128
|
+
if approval['success']
|
|
129
|
+
puts 'Invoice approved and ready to send.'
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Invoice Workflow
|
|
134
|
+
|
|
135
|
+
A typical invoice lifecycle:
|
|
136
|
+
|
|
137
|
+
1. **Create** the invoice with line items and tax
|
|
138
|
+
2. **Approve** the draft (optional, depending on your workflow)
|
|
139
|
+
3. **Send** the invoice to the customer (via email, SMS, or payment link)
|
|
140
|
+
4. **Collect** payment using a mobile money collection or payment link
|
|
141
|
+
5. **Record** the payment against the invoice
|
|
142
|
+
6. Invoice status moves to **Paid** when the full amount is received
|
|
143
|
+
|
|
144
|
+
### Combining Invoices with Payment Collection
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
# Step 1: Create the invoice
|
|
148
|
+
invoice = client.invoices.create(
|
|
149
|
+
customer_id: 'cust_abc123',
|
|
150
|
+
currency: 'TZS',
|
|
151
|
+
due_date: '2024-02-28',
|
|
152
|
+
items: [
|
|
153
|
+
{ description: 'Consulting (8 hours)', quantity: 8, unit_price: 150_000 }
|
|
154
|
+
]
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Step 2: Collect payment via M-Pesa
|
|
158
|
+
collection = client.payments.collect(
|
|
159
|
+
amount: 1_200_000,
|
|
160
|
+
currency: 'TZS',
|
|
161
|
+
phone: '255712345678',
|
|
162
|
+
provider: 'M-Pesa',
|
|
163
|
+
reference: 'inv_xyz789',
|
|
164
|
+
description: 'Invoice #INV-2024-0042 payment'
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Step 3: After webhook confirms payment, record it
|
|
168
|
+
record = client.invoices.record_payment(
|
|
169
|
+
invoice_id: 'inv_xyz789',
|
|
170
|
+
amount: 1_200_000,
|
|
171
|
+
reference: 'MPESA-TXN-GHI789',
|
|
172
|
+
payment_method: 'M-Pesa'
|
|
173
|
+
)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Error Handling
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
begin
|
|
180
|
+
invoice = client.invoices.create(
|
|
181
|
+
customer_id: 'nonexistent_customer',
|
|
182
|
+
currency: 'TZS',
|
|
183
|
+
items: [
|
|
184
|
+
{ description: 'Test', quantity: 1, unit_price: 1_000 }
|
|
185
|
+
]
|
|
186
|
+
)
|
|
187
|
+
rescue MaliPoPay::ValidationError => e
|
|
188
|
+
puts "Invalid invoice data: #{e.message}"
|
|
189
|
+
rescue MaliPoPay::NotFoundError
|
|
190
|
+
puts 'Customer not found. Create the customer first.'
|
|
191
|
+
rescue MaliPoPay::Error => e
|
|
192
|
+
puts "Invoice error: #{e.message}"
|
|
193
|
+
end
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Next Steps
|
|
197
|
+
|
|
198
|
+
- [Payments](./payments.md) -- collect payments for your invoices
|
|
199
|
+
- [Customers](./customers.md) -- manage the customers you invoice
|
|
200
|
+
- [Webhooks](./webhooks.md) -- get notified when payments are received
|
data/docs/payments.md
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# Payments
|
|
2
|
+
|
|
3
|
+
The `payments` resource handles all payment operations: mobile money collections, disbursements, payment verification, payment links, and search.
|
|
4
|
+
|
|
5
|
+
## How Payments Work in Tanzania
|
|
6
|
+
|
|
7
|
+
MaliPoPay integrates with Tanzania's major mobile money and banking networks:
|
|
8
|
+
|
|
9
|
+
- **Vodacom M-Pesa** -- largest mobile money network
|
|
10
|
+
- **Airtel Money** -- second-largest MNO
|
|
11
|
+
- **Tigo Pesa (Mixx by Yas)** -- merged MNO brand
|
|
12
|
+
- **Halotel Halopesa** -- growing rural network
|
|
13
|
+
- **TTCL T-Pesa** -- state telco mobile money
|
|
14
|
+
- **USSD (*146*08#)** -- unified USSD payment channel
|
|
15
|
+
- **Bank transfers** -- CRDB, NMB, and other banks via H2H integration
|
|
16
|
+
- **Card payments** -- Visa and Mastercard
|
|
17
|
+
|
|
18
|
+
### Collection Flow
|
|
19
|
+
|
|
20
|
+
1. Your app calls `collect` with the customer's phone number and amount
|
|
21
|
+
2. MaliPoPay sends a USSD push to the customer's phone
|
|
22
|
+
3. The customer sees a prompt like: *"Pay TZS 50,000 to ACME Ltd? Enter PIN to confirm"*
|
|
23
|
+
4. The customer enters their mobile money PIN
|
|
24
|
+
5. MaliPoPay receives the confirmation and notifies you via webhook
|
|
25
|
+
6. You can also poll using `verify`
|
|
26
|
+
|
|
27
|
+
### Disbursement Flow
|
|
28
|
+
|
|
29
|
+
1. Your app calls `disburse` with the recipient's phone/account and amount
|
|
30
|
+
2. MaliPoPay processes the transfer from your merchant float
|
|
31
|
+
3. The recipient receives the funds in their mobile money or bank account
|
|
32
|
+
4. You receive a webhook notification with the result
|
|
33
|
+
|
|
34
|
+
## Mobile Money Collection
|
|
35
|
+
|
|
36
|
+
Collect payments from any supported mobile money provider:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
# M-Pesa collection
|
|
40
|
+
mpesa = client.payments.collect(
|
|
41
|
+
amount: 75_000,
|
|
42
|
+
currency: 'TZS',
|
|
43
|
+
phone: '255712345678',
|
|
44
|
+
provider: 'M-Pesa',
|
|
45
|
+
reference: 'INV-2024-0042',
|
|
46
|
+
description: 'Invoice payment - Office furniture'
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Airtel Money collection
|
|
50
|
+
airtel = client.payments.collect(
|
|
51
|
+
amount: 25_000,
|
|
52
|
+
currency: 'TZS',
|
|
53
|
+
phone: '255782345678',
|
|
54
|
+
provider: 'Airtel Money',
|
|
55
|
+
reference: 'INV-2024-0043',
|
|
56
|
+
description: 'Delivery fee'
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Halopesa collection
|
|
60
|
+
halo = client.payments.collect(
|
|
61
|
+
amount: 10_000,
|
|
62
|
+
currency: 'TZS',
|
|
63
|
+
phone: '255622345678',
|
|
64
|
+
provider: 'Halopesa',
|
|
65
|
+
reference: 'INV-2024-0044',
|
|
66
|
+
description: 'Service charge'
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Tigo Pesa / Mixx by Yas collection
|
|
70
|
+
tigo = client.payments.collect(
|
|
71
|
+
amount: 35_000,
|
|
72
|
+
currency: 'TZS',
|
|
73
|
+
phone: '255652345678',
|
|
74
|
+
provider: 'Mixx',
|
|
75
|
+
reference: 'INV-2024-0045',
|
|
76
|
+
description: 'Consultation fee'
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# T-Pesa collection
|
|
80
|
+
tpesa = client.payments.collect(
|
|
81
|
+
amount: 20_000,
|
|
82
|
+
currency: 'TZS',
|
|
83
|
+
phone: '255742345678',
|
|
84
|
+
provider: 'T-Pesa',
|
|
85
|
+
reference: 'INV-2024-0046',
|
|
86
|
+
description: 'Registration fee'
|
|
87
|
+
)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Card Payments
|
|
91
|
+
|
|
92
|
+
Collect payments via Visa or Mastercard:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
card = client.payments.collect(
|
|
96
|
+
amount: 150_000,
|
|
97
|
+
currency: 'TZS',
|
|
98
|
+
provider: 'Card',
|
|
99
|
+
reference: 'INV-2024-0050',
|
|
100
|
+
description: 'Annual membership',
|
|
101
|
+
customer_email: 'juma@example.com',
|
|
102
|
+
redirect_url: 'https://yoursite.co.tz/payment/success'
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# The result includes a checkout URL for the customer
|
|
106
|
+
puts "Redirect customer to: #{card['checkout_url']}"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Disbursement
|
|
110
|
+
|
|
111
|
+
Send money to mobile money wallets or bank accounts:
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
# Disburse to M-Pesa wallet
|
|
115
|
+
disbursement = client.payments.disburse(
|
|
116
|
+
amount: 150_000,
|
|
117
|
+
currency: 'TZS',
|
|
118
|
+
phone: '255712345678',
|
|
119
|
+
provider: 'M-Pesa',
|
|
120
|
+
reference: 'PAYOUT-2024-001',
|
|
121
|
+
description: 'Salary payment - January 2024'
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Disburse to bank account (CRDB)
|
|
125
|
+
bank_disbursement = client.payments.disburse(
|
|
126
|
+
amount: 500_000,
|
|
127
|
+
currency: 'TZS',
|
|
128
|
+
account_number: '01J1234567890',
|
|
129
|
+
bank_code: 'CRDB',
|
|
130
|
+
account_name: 'Juma Hassan',
|
|
131
|
+
reference: 'PAYOUT-2024-002',
|
|
132
|
+
description: 'Vendor payment'
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Disburse to NMB account
|
|
136
|
+
nmb_disbursement = client.payments.disburse(
|
|
137
|
+
amount: 2_500_000,
|
|
138
|
+
currency: 'TZS',
|
|
139
|
+
account_number: '2345678901',
|
|
140
|
+
bank_code: 'NMB',
|
|
141
|
+
account_name: 'Maria Joseph',
|
|
142
|
+
reference: 'PAYOUT-2024-003',
|
|
143
|
+
description: 'Contractor payout'
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Payment Verification
|
|
148
|
+
|
|
149
|
+
After initiating a collection, verify the payment status. In production you should rely on webhooks, but polling is useful for synchronous flows and reconciliation:
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
# Verify by reference
|
|
153
|
+
status = client.payments.verify('INV-2024-0042')
|
|
154
|
+
|
|
155
|
+
if status['success']
|
|
156
|
+
puts "Payment status: #{status['status']}"
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Polling with Timeout
|
|
161
|
+
|
|
162
|
+
For cases where you need to wait for the customer to complete the payment:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
def wait_for_payment(client, reference, timeout: 120, interval: 5)
|
|
166
|
+
deadline = Time.now + timeout
|
|
167
|
+
|
|
168
|
+
while Time.now < deadline
|
|
169
|
+
result = client.payments.verify(reference)
|
|
170
|
+
|
|
171
|
+
return true if result['status'] == 'completed'
|
|
172
|
+
return false if result['status'] == 'failed'
|
|
173
|
+
|
|
174
|
+
sleep interval
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
false # timed out
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Wait up to 2 minutes, polling every 5 seconds
|
|
181
|
+
paid = wait_for_payment(client, 'INV-2024-0042')
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
> **Recommendation:** Use webhooks instead of polling in production. Polling is acceptable for testing, CLI tools, and reconciliation scripts.
|
|
185
|
+
|
|
186
|
+
## Payment Links
|
|
187
|
+
|
|
188
|
+
Generate a shareable payment link that customers can use to pay via any supported method:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
link = client.payments.pay(
|
|
192
|
+
amount: 250_000,
|
|
193
|
+
currency: 'TZS',
|
|
194
|
+
reference: 'LINK-2024-001',
|
|
195
|
+
description: 'Annual membership fee',
|
|
196
|
+
customer_name: 'Asha Mwalimu',
|
|
197
|
+
customer_email: 'asha@example.com',
|
|
198
|
+
customer_phone: '255712345678',
|
|
199
|
+
redirect_url: 'https://yoursite.co.tz/payment/success',
|
|
200
|
+
callback_url: 'https://yoursite.co.tz/api/webhooks/malipopay'
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
puts "Send this link to the customer: #{link['payment_url']}"
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Retry Failed Collections
|
|
207
|
+
|
|
208
|
+
If a collection failed due to a transient issue (timeout, network error), you can retry it:
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
retry_result = client.payments.retry('INV-2024-0042')
|
|
212
|
+
|
|
213
|
+
if retry_result['success']
|
|
214
|
+
puts 'Collection retry initiated. Customer will receive a new USSD push.'
|
|
215
|
+
end
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## List and Search Payments
|
|
219
|
+
|
|
220
|
+
### List All Payments
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
payments = client.payments.list
|
|
224
|
+
|
|
225
|
+
if payments['success']
|
|
226
|
+
puts "Total payments: #{payments['data'].length}"
|
|
227
|
+
end
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Search Payments
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
results = client.payments.search
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Get Payment by Reference
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
payment = client.payments.get_by_reference('INV-2024-0042')
|
|
240
|
+
|
|
241
|
+
if payment['success']
|
|
242
|
+
puts "Amount: #{payment['data']['amount']}"
|
|
243
|
+
puts "Status: #{payment['data']['status']}"
|
|
244
|
+
puts "Provider: #{payment['data']['provider']}"
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Error Handling for Payments
|
|
249
|
+
|
|
250
|
+
Always wrap payment operations in begin/rescue:
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
begin
|
|
254
|
+
result = client.payments.collect(
|
|
255
|
+
amount: 50_000,
|
|
256
|
+
currency: 'TZS',
|
|
257
|
+
phone: '255712345678',
|
|
258
|
+
provider: 'M-Pesa',
|
|
259
|
+
reference: 'ORDER-001',
|
|
260
|
+
description: 'Test payment'
|
|
261
|
+
)
|
|
262
|
+
rescue MaliPoPay::ValidationError => e
|
|
263
|
+
# Invalid parameters (wrong phone format, missing fields, etc.)
|
|
264
|
+
puts "Validation error: #{e.message}"
|
|
265
|
+
rescue MaliPoPay::AuthenticationError
|
|
266
|
+
# Bad API key
|
|
267
|
+
puts 'Check your API key.'
|
|
268
|
+
rescue MaliPoPay::RateLimitError
|
|
269
|
+
# Too many requests -- back off and retry
|
|
270
|
+
puts 'Rate limited. Please wait and retry.'
|
|
271
|
+
rescue MaliPoPay::ConnectionError => e
|
|
272
|
+
# Network issue
|
|
273
|
+
puts "Network error: #{e.message}"
|
|
274
|
+
rescue MaliPoPay::Error => e
|
|
275
|
+
# Catch-all for other SDK errors
|
|
276
|
+
puts "Payment error: #{e.message}"
|
|
277
|
+
end
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Next Steps
|
|
281
|
+
|
|
282
|
+
- [Webhooks](./webhooks.md) -- receive real-time payment status updates
|
|
283
|
+
- [Invoices](./invoices.md) -- create invoices and record payments against them
|
|
284
|
+
- [Error Handling](./error-handling.md) -- comprehensive error handling patterns
|
data/docs/sms.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# SMS
|
|
2
|
+
|
|
3
|
+
The `sms` resource lets you send transactional and promotional SMS messages to Tanzanian phone numbers. You can send single messages, bulk messages to multiple recipients, and schedule messages for future delivery.
|
|
4
|
+
|
|
5
|
+
## Sending a Single SMS
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
sms = client.sms.send_message(
|
|
9
|
+
to: '255712345678',
|
|
10
|
+
message: 'Your payment of TZS 50,000 has been confirmed. Reference: ORD-2024-100. Thank you for shopping with us!',
|
|
11
|
+
sender_id: 'MALIPOPAY'
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
if sms['success']
|
|
15
|
+
puts "SMS sent: #{sms['data']}"
|
|
16
|
+
else
|
|
17
|
+
puts "Failed: #{sms['message']}"
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### With Error Handling
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
begin
|
|
25
|
+
sms = client.sms.send_message(
|
|
26
|
+
to: '255754321098',
|
|
27
|
+
message: 'Dear Amina, your invoice INV-2024-042 of TZS 3,746,500 is due on 15 March 2024. Pay via M-Pesa to avoid late fees.',
|
|
28
|
+
sender_id: 'ACME'
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
puts "Message delivered: #{sms['data']}"
|
|
32
|
+
rescue MaliPoPay::ValidationError => e
|
|
33
|
+
puts "Invalid request: #{e.message}"
|
|
34
|
+
rescue MaliPoPay::Error => e
|
|
35
|
+
puts "SMS error: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Bulk SMS
|
|
40
|
+
|
|
41
|
+
Send the same message or different messages to multiple recipients at once.
|
|
42
|
+
|
|
43
|
+
### Same Message to Multiple Recipients
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
bulk = client.sms.send_bulk(
|
|
47
|
+
recipients: [
|
|
48
|
+
'255712345678',
|
|
49
|
+
'255754321098',
|
|
50
|
+
'255622345678',
|
|
51
|
+
'255652345678',
|
|
52
|
+
'255742345678'
|
|
53
|
+
],
|
|
54
|
+
message: 'Reminder: Our office will be closed on Monday 1st January for the New Year holiday. Happy New Year from ACME Ltd!',
|
|
55
|
+
sender_id: 'ACME'
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if bulk['success']
|
|
59
|
+
puts "Bulk SMS sent to #{bulk['data']} recipients"
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Individual Messages per Recipient
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
personalized = client.sms.send_bulk(
|
|
67
|
+
messages: [
|
|
68
|
+
{
|
|
69
|
+
to: '255712345678',
|
|
70
|
+
message: 'Hi Juma, your account balance is TZS 125,000. Login at app.malipopay.co.tz to view details.'
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
to: '255754321098',
|
|
74
|
+
message: 'Hi Amina, your account balance is TZS 340,500. Login at app.malipopay.co.tz to view details.'
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
to: '255622345678',
|
|
78
|
+
message: 'Hi Baraka, your account balance is TZS 78,200. Login at app.malipopay.co.tz to view details.'
|
|
79
|
+
}
|
|
80
|
+
],
|
|
81
|
+
sender_id: 'ACME'
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Scheduling SMS
|
|
86
|
+
|
|
87
|
+
Schedule messages for future delivery by providing a `scheduled_at` timestamp in ISO 8601 format. All times are in East Africa Time (EAT, UTC+3):
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
# Schedule a payment reminder for 9:00 AM tomorrow
|
|
91
|
+
scheduled = client.sms.send_message(
|
|
92
|
+
to: '255712345678',
|
|
93
|
+
message: 'Reminder: Your subscription of TZS 15,000 is due tomorrow. Pay now via M-Pesa to avoid interruption.',
|
|
94
|
+
sender_id: 'ACME',
|
|
95
|
+
scheduled_at: '2024-03-15T09:00:00+03:00'
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if scheduled['success']
|
|
99
|
+
puts "SMS scheduled: #{scheduled['data']}"
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Schedule Bulk SMS
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
# Schedule a promotional message for Friday at 2:00 PM
|
|
107
|
+
scheduled_bulk = client.sms.send_bulk(
|
|
108
|
+
recipients: [
|
|
109
|
+
'255712345678',
|
|
110
|
+
'255754321098',
|
|
111
|
+
'255652345678'
|
|
112
|
+
],
|
|
113
|
+
message: 'Weekend offer! Get 20% off all services this Saturday and Sunday. Visit our shop on Samora Avenue or pay via M-Pesa. Code: WEEKEND20',
|
|
114
|
+
sender_id: 'ACME',
|
|
115
|
+
scheduled_at: '2024-03-15T14:00:00+03:00'
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Sender IDs
|
|
120
|
+
|
|
121
|
+
The `sender_id` field controls what appears as the sender on the recipient's phone. This is the name or short code displayed instead of a phone number.
|
|
122
|
+
|
|
123
|
+
| Sender ID | Description |
|
|
124
|
+
|-----------|-------------|
|
|
125
|
+
| `MALIPOPAY` | Default MaliPoPay sender ID |
|
|
126
|
+
| Custom (e.g., `ACME`) | Your registered brand name |
|
|
127
|
+
|
|
128
|
+
### Registering a Custom Sender ID
|
|
129
|
+
|
|
130
|
+
Custom sender IDs must be registered and approved in your MaliPoPay dashboard:
|
|
131
|
+
|
|
132
|
+
1. Go to [app.malipopay.co.tz](https://app.malipopay.co.tz) > **Settings > SMS > Sender IDs**
|
|
133
|
+
2. Click **Request New Sender ID**
|
|
134
|
+
3. Enter your desired sender name (up to 11 characters, alphanumeric)
|
|
135
|
+
4. Submit for approval -- this typically takes 24-48 hours
|
|
136
|
+
|
|
137
|
+
> **Note:** TCRA regulations in Tanzania require sender IDs to be registered. Unregistered sender IDs will be replaced with a default numeric sender.
|
|
138
|
+
|
|
139
|
+
## SMS Character Limits
|
|
140
|
+
|
|
141
|
+
Standard SMS messages have a 160-character limit per segment. Messages longer than 160 characters are split into multiple segments and reassembled on the recipient's device:
|
|
142
|
+
|
|
143
|
+
| Length | Segments | Effective Characters per Segment |
|
|
144
|
+
|--------|----------|----------------------------------|
|
|
145
|
+
| 1--160 | 1 | 160 |
|
|
146
|
+
| 161--306 | 2 | 153 (7 bytes used for concatenation header) |
|
|
147
|
+
| 307--459 | 3 | 153 |
|
|
148
|
+
|
|
149
|
+
Billing is per segment. Keep messages concise to minimize costs.
|
|
150
|
+
|
|
151
|
+
## Complete Example: Payment Confirmation SMS
|
|
152
|
+
|
|
153
|
+
A common pattern is sending an SMS after a successful payment webhook:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
# In your Sinatra/Rails webhook handler
|
|
157
|
+
post '/webhooks/malipopay' do
|
|
158
|
+
# ... verify signature (see Webhooks guide) ...
|
|
159
|
+
|
|
160
|
+
event = verifier.construct_event(payload, signature)
|
|
161
|
+
|
|
162
|
+
if event['event_type'] == 'payment.completed'
|
|
163
|
+
client.sms.send_message(
|
|
164
|
+
to: event['phone'],
|
|
165
|
+
message: "Payment confirmed! TZS #{event['amount']} received for #{event['reference']}. " \
|
|
166
|
+
"Transaction ID: #{event['transaction_id']}. Thank you!",
|
|
167
|
+
sender_id: 'ACME'
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
puts "Confirmation SMS sent to #{event['phone']}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
status 200
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Next Steps
|
|
178
|
+
|
|
179
|
+
- [Payments](./payments.md) -- collect payments that trigger SMS notifications
|
|
180
|
+
- [Webhooks](./webhooks.md) -- automate SMS sending from webhook events
|
|
181
|
+
- [Error Handling](./error-handling.md) -- handle SMS delivery failures
|
|
182
|
+
- [Configuration](./configuration.md) -- configure timeouts and retries
|