webmobyle/dpo-payments
最新稳定版本:1.0.1
Composer 安装命令:
composer require webmobyle/dpo-payments
包简介
Drop-in DPO (3G Direct Pay) payments for Laravel with optional recurring billing (tokenization, XML v6) and Mobile Money (MoMo) payments without redirect.
README 文档
README
A lightweight Laravel package for integrating DPO (3G Direct Pay) payments — including one-time and tokenized recurring payments — with a clean, expressive API as well as Mobile Money Payments.
🚀 Features
- Generate payment tokens and redirect URLs
- Verify payment status after checkout
- Handle recurring / tokenized billing
- Supports Laravel 10, 11 and 12
- Simple facade API (
Dpo::createTokenAndRedirectUrl()) - Built-in logging and timeout handling
- Easy configuration via
config/dpo.php - Mobile Money (MoMo) Payments without Redirect
See CHANGELOG.md for release history.
🧩 Installation
composer require webmobyle/dpo-payments
Laravel 10+ supports auto-discovery, so no manual provider registration is required. The package expects users to be authenticated.
⚙️ Configuration
Publish the config and migration files:
php artisan vendor:publish --tag=dpo-config
php artisan vendor:publish --tag=dpo-migrations
php artisan migrate
Then edit .env with your DPO credentials:
DPO_API_URL="https://secure.3gdirectpay.com/API/v6/"
DPO_COMPANY_TOKEN=dpo_company_token
DPO_SERVICE_TYPE=dpo_service_type
DPO_PTL=15
DPO_RECURRING_ENABLED=true
#dpo_silent to disable logging
DPO_LOG_CHANNEL=stack
DPO_ALLOW_RECURRENT_ON_INITIAL=true
DPO_ALLOW_RECURRENT_ON_REBILL=false
DPO_HTTP_TIMEOUT=30
DPO_HTTP_CONNECT_TIMEOUT=8
DPO_HTTP_READ_TIMEOUT=45
DPO_HTTP_RETRIES=3
DPO_MOMO_OPTIONS_TTL=3600
DPO_MOMO_DEFAULT_COUNTRY=MW
DPO_MOMO_MAX_RETRIES=30
DPO_MOMO_POLL_DELAY_MS=4000
DPO_MOMO_STATUS_RETRIES=30
💳 Basic Usage
Create a payment with a subscription and redirect
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Webmobyle\Dpo\Facades\Dpo;
public function create(Request $request)
{
$user = Auth::user();
$companyRef = 'USER-' . $user->id . '-' . now()->format('YmdHi');
// 3) Create a single token
$redirectUrl = Dpo::createTokenAndRedirectUrl(
amount: 0.70,
currency: 'USD',
companyRef: $companyRef,
customerFirstName: 'Barnett',
customerLastName: 'Msiska',
customerZip: '0000',
customerCity: 'Lilongwe',
customerCountry: 'MW',
customerEmail: $user->email ?? '', // use the real email, not a placeholder
serviceDescription: 'Example service description',
forRebill: true // Set to False for once off payment
);
return redirect($redirectUrl);
}
// routes/web.php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\PaymentController;
use Webmobyle\Dpo\Http\Controllers\DpoMomoController;
Route::get('/', function () {
return view('welcome');
});
Route::middleware(['web','auth'])->group(function () {
Route::get('payment/create', [PaymentController::class, 'create']);
Route::get('/checkout/momo', [DpoMomoController::class, 'form'])->name('momo.form');
});
🔁 Recurring Payments (Optional)
- Once a customer completes an initial payment and you receive a tokenized reference, you can charge it later if foreRebill is set to true when calling create()
- NOTE: The package does not check whether a user has multiple subscriptions. This is something you have to manage yourself.
A scheduler bills recurring payments on the anchor day on the day of payment and a time set in the .env DPO_BILL_TIME variable.
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('dpo:run-billing')->hourly();
🧠 Models
This package includes Eloquent models you can extend or observe:
| Model | Purpose |
|---|---|
Webmobyle\Dpo\Models\DpoCharge | Stores individual payment attempts |
Webmobyle\Dpo\Models\Subscription | Stores subscriptions |
By default, migrations are not published; you can run migrations as indicatated in the configuration section above.
📄 Views
Make sure to implement views for the following routes with success and error flash messages
// routes/web.php
Route::view('/dpo/redirect', 'dpo.redirect');
Route::view('/dpo/back-redirect', 'dpo.back-redirect');
📱Mobile Money Payments (Optional)
Create a payment without redirecting the user
Implement a view similar to this one;
{{-- resources/views/checkout/momo.blade.php --}}
@extends('layouts.app')
@section('content')
<div class="container" style="max-width:720px;">
<h1 class="mb-4">Mobile Money Payment</h1>
<div class="card shadow-sm">
<div class="card-body">
<form id="momo-form">
@csrf
<div class="row g-3">
<div class="col-12">
<label class="form-label">Description</label>
<input type="text" class="form-control" id="description"
placeholder="Product/Service Description" value="Example order description" required>
</div>
<div class="col-md-6">
<label class="form-label">Amount</label>
<input type="number" step="0.01" min="0.70" class="form-control" id="amount"
value="0.70" required>
</div>
<div class="col-md-6">
<label class="form-label">Currency</label>
<select class="form-select" id="currency">
{{-- Add Relevat Currencies --}}
<option value="USD">USD</option>
<option value="MWK">MWK</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Country</label>
<select class="form-select" id="country" onchange="loadProviders()">
<option value="MW" selected>Malawi</option>
<option value="GH">Ghana</option>
<option value="CI">Ivory Coast</option>
<option value="KE">Kenya</option>
<option value="RW">Rwanda</option>
<option value="TZ">Tanzania</option>
<option value="UG">Uganda</option>
<option value="ZM">Zambia</option>
<option value="ZW">Zimbabwe</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Provider</label>
<select class="form-select" id="provider"></select>
<div class="form-text">Loaded from DPO options for selected country.</div>
</div>
<div class="col-12">
<label class="form-label">Phone (MSISDN - With Country Code, No +)</label>
<input type="tel" class="form-control" id="phone" placeholder="265991234567" required>
</div>
</div>
<div class="d-flex align-items-center gap-3 mt-4">
<button type="submit" class="btn btn-primary" id="payBtn">
Send Payment Prompt
</button>
<div id="statusMsg" class="text-muted"></div>
</div>
</form>
<hr class="my-4">
<div id="resultBox" class="alert d-none" role="alert"></div>
</div>
</div>
</div>
@endsection
@push('scripts')
@php
$dpoMomo = [
'maxRetries' => (int) config('dpo.momo.max_retries', 30),
'pollDelayMs' => (int) config('dpo.momo.poll_delay_ms', 4000),
];
@endphp
<script>
window.DPO_MOMO = @json($dpoMomo);
</script>
<script>
let lastTransToken = null;
const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
async function loadProviders() {
const description = document.getElementById('description').value;
const country = document.getElementById('country').value;
const amount = parseFloat(document.getElementById('amount').value);
const currency = document.getElementById('currency').value;
const providerSel = document.getElementById('provider');
providerSel.innerHTML = '<option>Loading…</option>';
lastTransToken = null;
try {
const res = await fetch('/api/dpo/momo/options', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': csrf
},
body: JSON.stringify({
description,
amount,
currency,
country
})
});
const data = await res.json();
providerSel.innerHTML = '';
if (data.ok && Array.isArray(data.providers) && data.providers.length) {
data.providers.forEach(p => {
const o = document.createElement('option');
o.value = p;
o.textContent = p;
providerSel.appendChild(o);
});
lastTransToken = data.transaction_token; // save for initiate
} else {
providerSel.innerHTML = '<option value="">No providers</option>';
}
} catch (e) {
providerSel.innerHTML = '<option value="">Error loading</option>';
console.error(e);
}
}
document.addEventListener('DOMContentLoaded', () => {
loadProviders();
document.getElementById('country').addEventListener('change', loadProviders);
document.getElementById('currency').addEventListener('change', loadProviders);
document.getElementById('amount').addEventListener('change', loadProviders);
const form = document.getElementById('momo-form');
form.addEventListener('submit', (e) => {
e.preventDefault();
startMomo();
});
});
async function startMomo() {
const btn = document.getElementById('payBtn');
const msg = document.getElementById('statusMsg');
const result = document.getElementById('resultBox');
btn.disabled = true;
msg.textContent = 'Creating payment…';
result.className = 'alert d-none';
const payload = {
description: document.getElementById('description').value,
amount: parseFloat(document.getElementById('amount').value),
currency: document.getElementById('currency').value,
country: document.getElementById('country').value,
phone: document.getElementById('phone').value,
payment_name: document.getElementById('provider').value || null,
transaction_token: lastTransToken, // reuse the token we created for options
};
try {
const res = await fetch('/api/dpo/momo/initiate', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrf,
'Accept': 'application/json'
},
body: JSON.stringify(payload)
});
const data = await res.json();
if (!data.ok) throw new Error(data.message || 'Failed to initiate.');
msg.textContent = 'Prompt sent. Waiting for approval…';
await pollStatus(
data.transaction_token,
(window.DPO_MOMO?.maxRetries ?? 30),
(window.DPO_MOMO?.pollDelayMs ?? 4000)
);
} catch (e) {
result.className = 'alert alert-danger';
result.textContent = e.message || 'Something went wrong.';
msg.textContent = '';
} finally {
btn.disabled = false;
}
}
async function pollStatus(tt, maxTries = 30, delayMs = 4000) {
const result = document.getElementById('resultBox');
const msg = document.getElementById('statusMsg');
for (let i = 0; i < maxTries; i++) {
await new Promise(r => setTimeout(r, delayMs));
try {
const res = await fetch('/api/dpo/momo/status?transaction_token=' + encodeURIComponent(tt), {
method: 'GET',
credentials: 'same-origin',
headers: {
'Accept': 'application/json'
}
});
const data = await res.json();
if (data.paid) {
result.className = 'alert alert-success';
result.textContent = 'Payment successful.';
msg.textContent = '';
return;
}
msg.textContent = 'Still pending… ' + (data.explanation || '');
} catch {
msg.textContent = 'Checking status…';
}
}
result.className = 'alert alert-warning';
result.textContent =
'Timed out waiting for approval. You can try again. Make sure to provide the correct Phone number and that you have sufficient funds.';
msg.textContent = '';
}
</script>
@endpush
🧱 Requirements
| Dependency | Version |
|---|---|
| PHP | ^8.2 |
| Laravel | 10.x - 12.x |
| GuzzleHTTP | ^7.8 |
📦 Versioning
This package follows Semantic Versioning (SemVer) — tag releases like v1.0.0, v1.1.0, etc.
🪪 License
This package is open-sourced software licensed under the MIT License.
👤 Author
Barnett Temwa Msiska
Founder, Webmobyle Limited
📧 barnett@webmobyle.com
⭐ Support
If you find this package useful, please star it on Packagist or Bitbucket.
Contributions, pull requests, and issues are welcome!
Email: contact@webmobyle.com
统计信息
- 总下载量: 10
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 1
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2025-11-14