定制 webmobyle/dpo-payments 二次开发

按需修改功能、优化性能、对接业务系统,提供一站式技术支持

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

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

Packagist Version License Laravel

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:

ModelPurpose
Webmobyle\Dpo\Models\DpoChargeStores individual payment attempts
Webmobyle\Dpo\Models\SubscriptionStores 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

DependencyVersion
PHP^8.2
Laravel10.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
  • 点击次数: 0
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2025-11-14