chore: 补充财务与支付模块源码
Some checks failed
Tests / PHP 8.1 (push) Has been cancelled
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled

This commit is contained in:
WuYongJie 2026-07-20 18:40:05 +08:00
parent 9595267ee7
commit 2b6c4030e5
116 changed files with 20681 additions and 0 deletions

6
.gitignore vendored
View File

@ -94,5 +94,11 @@ _ide_helper_models.php
.phpunit.result.cache
modules/*
!modules/.gitkeep
!modules/EcommerceFinance/
!modules/EcommerceFinance/**
!modules/OfflinePayments/
!modules/OfflinePayments/**
!modules/PaypalStandard/
!modules/PaypalStandard/**
.laravelstatsrc
.DS_Store

View File

@ -0,0 +1,231 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ec_channels', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->string('code', 64);
$table->string('name');
$table->string('type', 32)->default('marketplace');
$table->boolean('enabled')->default(true);
$table->unsignedInteger('created_by')->nullable();
$table->string('created_from', 100)->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['company_id', 'code', 'deleted_at']);
});
Schema::create('ec_stores', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->unsignedInteger('channel_id');
$table->string('name');
$table->string('external_id', 128);
$table->unsignedInteger('settlement_account_id')->nullable();
$table->string('timezone', 64)->default('Asia/Shanghai');
$table->string('currency_code', 3)->default('CNY');
$table->boolean('enabled')->default(true);
$table->unsignedInteger('created_by')->nullable();
$table->string('created_from', 100)->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['company_id', 'channel_id']);
$table->unique(['company_id', 'channel_id', 'external_id', 'deleted_at']);
});
Schema::create('ec_sku_mappings', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->unsignedInteger('store_id');
$table->unsignedInteger('item_id')->nullable();
$table->string('external_sku_id', 128);
$table->string('external_sku_code', 128)->nullable();
$table->string('external_sku_name')->nullable();
$table->unsignedInteger('created_by')->nullable();
$table->string('created_from', 100)->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['store_id', 'external_sku_id', 'deleted_at']);
$table->index(['company_id', 'item_id']);
});
Schema::create('ec_import_batches', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->unsignedInteger('store_id')->nullable();
$table->string('source_type', 32);
$table->string('source_file');
$table->string('checksum', 64);
$table->string('status', 32)->default('queued');
$table->unsignedInteger('row_count')->default(0);
$table->unsignedInteger('success_count')->default(0);
$table->unsignedInteger('error_count')->default(0);
$table->json('summary')->nullable();
$table->text('error_message')->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->unsignedInteger('created_by')->nullable();
$table->string('created_from', 100)->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['company_id', 'source_type', 'checksum']);
$table->index(['company_id', 'status']);
});
Schema::create('ec_orders', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->unsignedInteger('store_id');
$table->string('external_order_id', 128);
$table->string('order_status', 32);
$table->dateTime('ordered_at')->nullable();
$table->dateTime('paid_at')->nullable();
$table->dateTime('shipped_at')->nullable();
$table->dateTime('completed_at')->nullable();
$table->string('currency_code', 3)->default('CNY');
$table->decimal('gross_amount', 20, 6)->default(0);
$table->decimal('buyer_paid_amount', 20, 6)->default(0);
$table->decimal('platform_subsidy_amount', 20, 6)->default(0);
$table->decimal('merchant_subsidy_amount', 20, 6)->default(0);
$table->decimal('refunded_amount', 20, 6)->default(0);
$table->json('payload')->nullable();
$table->unsignedInteger('created_by')->nullable();
$table->string('created_from', 100)->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['store_id', 'external_order_id']);
$table->index(['company_id', 'order_status']);
$table->index(['company_id', 'completed_at']);
});
Schema::create('ec_order_items', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->unsignedInteger('order_id');
$table->unsignedInteger('item_id')->nullable();
$table->string('external_line_id', 128);
$table->string('sku_code', 128)->nullable();
$table->string('name');
$table->decimal('quantity', 20, 6);
$table->decimal('sale_amount', 20, 6)->default(0);
$table->decimal('discount_amount', 20, 6)->default(0);
$table->decimal('refund_amount', 20, 6)->default(0);
$table->decimal('cost_amount', 20, 6)->default(0);
$table->unsignedInteger('created_by')->nullable();
$table->string('created_from', 100)->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['order_id', 'external_line_id']);
$table->index(['company_id', 'item_id']);
});
Schema::create('ec_order_events', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->unsignedInteger('order_id');
$table->string('event_type', 32);
$table->string('external_event_id', 128);
$table->dateTime('event_at');
$table->json('payload')->nullable();
$table->unsignedInteger('created_by')->nullable();
$table->string('created_from', 100)->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['order_id', 'event_type', 'external_event_id']);
$table->index(['company_id', 'event_at']);
});
Schema::create('ec_settlements', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->unsignedInteger('store_id');
$table->unsignedInteger('transaction_id')->nullable();
$table->string('external_settlement_id', 128);
$table->dateTime('period_start')->nullable();
$table->dateTime('period_end')->nullable();
$table->dateTime('settled_at')->nullable();
$table->string('currency_code', 3)->default('CNY');
$table->decimal('gross_amount', 20, 6)->default(0);
$table->decimal('fee_amount', 20, 6)->default(0);
$table->decimal('refund_amount', 20, 6)->default(0);
$table->decimal('net_amount', 20, 6)->default(0);
$table->string('status', 32)->default('imported');
$table->json('payload')->nullable();
$table->unsignedInteger('created_by')->nullable();
$table->string('created_from', 100)->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['store_id', 'external_settlement_id']);
$table->index(['company_id', 'status']);
$table->index(['company_id', 'settled_at']);
});
Schema::create('ec_settlement_lines', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->unsignedInteger('settlement_id');
$table->string('line_type', 32);
$table->string('external_line_id', 128);
$table->string('external_order_id', 128)->nullable();
$table->decimal('amount', 20, 6);
$table->dateTime('occurred_at')->nullable();
$table->json('payload')->nullable();
$table->unsignedInteger('created_by')->nullable();
$table->string('created_from', 100)->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['settlement_id', 'external_line_id']);
$table->index(['company_id', 'external_order_id']);
});
Schema::create('ec_reconciliation_matches', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->unsignedInteger('settlement_line_id');
$table->unsignedInteger('order_id')->nullable();
$table->string('match_type', 32);
$table->decimal('matched_amount', 20, 6)->default(0);
$table->string('status', 32)->default('unmatched');
$table->string('rule_version', 32)->nullable();
$table->unsignedInteger('confirmed_by')->nullable();
$table->timestamp('confirmed_at')->nullable();
$table->text('note')->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['settlement_line_id', 'order_id']);
$table->index(['company_id', 'status']);
$table->index(['company_id', 'order_id']);
});
}
public function down(): void
{
Schema::dropIfExists('ec_reconciliation_matches');
Schema::dropIfExists('ec_settlement_lines');
Schema::dropIfExists('ec_settlements');
Schema::dropIfExists('ec_order_events');
Schema::dropIfExists('ec_order_items');
Schema::dropIfExists('ec_orders');
Schema::dropIfExists('ec_import_batches');
Schema::dropIfExists('ec_sku_mappings');
Schema::dropIfExists('ec_stores');
Schema::dropIfExists('ec_channels');
}
};

View File

@ -0,0 +1,50 @@
<?php
namespace Modules\EcommerceFinance\Http\Controllers\Api;
use App\Abstracts\Http\ApiController;
use Modules\EcommerceFinance\Http\Requests\Channel as Request;
use Modules\EcommerceFinance\Models\Channel;
class Channels extends ApiController
{
public function index()
{
return response()->json(Channel::query()->orderBy('name')->paginate($this->limit()));
}
public function show(Channel $channel)
{
return response()->json($channel);
}
public function store(Request $request)
{
$channel = Channel::create($this->attributes($request));
return response()
->json($channel, 201)
->header('Location', route('api.ecommerce-finance.channels.show', $channel));
}
public function update(Channel $channel, Request $request)
{
$channel->update($request->validated());
return response()->json($channel->fresh());
}
private function attributes(Request $request): array
{
return array_merge($request->validated(), [
'company_id' => company_id(),
'created_by' => user_id(),
'created_from' => 'ecommerce-finance',
]);
}
private function limit(): int
{
return min(max((int) request('limit', 25), 1), 100);
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace Modules\EcommerceFinance\Http\Controllers\Api;
use App\Abstracts\Http\ApiController;
use Illuminate\Database\QueryException;
use Modules\EcommerceFinance\Http\Requests\ImportBatch as Request;
use Modules\EcommerceFinance\Jobs\ProcessImportBatch;
use Modules\EcommerceFinance\Models\ImportBatch;
use Illuminate\Support\Facades\Storage;
class ImportBatches extends ApiController
{
public function index()
{
$batches = ImportBatch::with('store.channel')
->latest('id')
->paginate($this->limit());
return response()->json($batches);
}
public function show(ImportBatch $importBatch)
{
return response()->json($importBatch->load('store.channel'));
}
public function store(Request $request)
{
if (! Storage::disk('uploads')->exists($request->input('source_file'))) {
return $this->errorBadRequest('The source file does not exist in the private uploads storage.');
}
try {
$batch = ImportBatch::create(array_merge($request->validated(), [
'company_id' => company_id(),
'status' => 'queued',
'created_by' => user_id(),
'created_from' => 'ecommerce-finance',
]));
} catch (QueryException $exception) {
return $this->errorBadRequest('An identical import batch has already been created.');
}
$this->dispatch(new ProcessImportBatch($batch->id));
$batch->refresh();
return response()
->json($batch, 201)
->header('Location', route('api.ecommerce-finance.import-batches.show', $batch));
}
private function limit(): int
{
return min(max((int) request('limit', 25), 1), 100);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Modules\EcommerceFinance\Http\Controllers\Api;
use App\Abstracts\Http\ApiController;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Modules\EcommerceFinance\Http\Requests\ImportFile as Request;
class ImportFiles extends ApiController
{
public function store(Request $request)
{
$file = $request->file('source_file');
$checksum = hash_file('sha256', $file->getRealPath());
$extension = strtolower($file->getClientOriginalExtension() ?: 'csv');
$path = 'ecommerce-finance/' . company_id() . '/' . Str::uuid() . '.' . $extension;
Storage::disk('uploads')->putFileAs(
dirname($path),
$file,
basename($path)
);
return response()->json([
'source_file' => $path,
'checksum' => $checksum,
], 201);
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Modules\EcommerceFinance\Http\Controllers\Api;
use App\Abstracts\Http\ApiController;
use Modules\EcommerceFinance\Models\ReconciliationMatch;
class ReconciliationExceptions extends ApiController
{
public function index()
{
$matches = ReconciliationMatch::with('settlementLine.settlement.store', 'order')
->whereIn('status', ['unmatched', 'partial_matched'])
->latest('id')
->paginate(min(max((int) request('limit', 25), 1), 100));
return response()->json($matches);
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace Modules\EcommerceFinance\Http\Controllers\Api;
use App\Abstracts\Http\ApiController;
use Modules\EcommerceFinance\Http\Requests\Store as Request;
use Modules\EcommerceFinance\Models\Store;
class Stores extends ApiController
{
public function index()
{
$stores = Store::with('channel', 'settlementAccount')
->orderBy('name')
->paginate($this->limit());
return response()->json($stores);
}
public function show(Store $store)
{
return response()->json($store->load('channel', 'settlementAccount'));
}
public function store(Request $request)
{
$store = Store::create($this->attributes($request));
return response()
->json($store, 201)
->header('Location', route('api.ecommerce-finance.stores.show', $store));
}
public function update(Store $store, Request $request)
{
$store->update($request->validated());
return response()->json($store->fresh()->load('channel', 'settlementAccount'));
}
private function attributes(Request $request): array
{
return array_merge($request->validated(), [
'company_id' => company_id(),
'created_by' => user_id(),
'created_from' => 'ecommerce-finance',
]);
}
private function limit(): int
{
return min(max((int) request('limit', 25), 1), 100);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace Modules\EcommerceFinance\Http\Requests;
use App\Abstracts\Http\FormRequest;
use Illuminate\Validation\Rule;
class Channel extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'code' => [
'required',
'string',
'max:64',
Rule::unique('ec_channels', 'code')
->where('company_id', company_id())
->ignore($this->route('channel')),
],
'name' => ['required', 'string', 'max:255'],
'type' => ['required', 'string', 'max:32'],
'enabled' => ['sometimes', 'boolean'],
];
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace Modules\EcommerceFinance\Http\Requests;
use App\Abstracts\Http\FormRequest;
use Illuminate\Validation\Rule;
class ImportBatch extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'store_id' => ['required', 'integer', Rule::exists('ec_stores', 'id')->where('company_id', company_id())],
'source_type' => ['required', Rule::in(['orders', 'settlements'])],
'source_file' => ['required', 'string', 'max:255'],
'checksum' => ['required', 'string', 'size:64', 'regex:/^[a-f0-9]{64}$/i'],
];
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace Modules\EcommerceFinance\Http\Requests;
use App\Abstracts\Http\FormRequest;
class ImportFile extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'source_file' => ['required', 'file', 'mimes:csv,txt', 'max:10240'],
];
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace Modules\EcommerceFinance\Http\Requests;
use App\Abstracts\Http\FormRequest;
use Illuminate\Validation\Rule;
class Store extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'channel_id' => ['required', 'integer', Rule::exists('ec_channels', 'id')->where('company_id', company_id())],
'name' => ['required', 'string', 'max:255'],
'external_id' => [
'required',
'string',
'max:128',
Rule::unique('ec_stores', 'external_id')
->where('company_id', company_id())
->where('channel_id', $this->input('channel_id'))
->ignore($this->route('store')),
],
'settlement_account_id' => ['nullable', 'integer', Rule::exists('accounts', 'id')->where('company_id', company_id())],
'timezone' => ['sometimes', 'timezone'],
'currency_code' => ['sometimes', 'string', 'size:3'],
'enabled' => ['sometimes', 'boolean'],
];
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace Modules\EcommerceFinance\Jobs;
use App\Abstracts\JobShouldQueue;
use Modules\EcommerceFinance\Models\Order;
use Modules\EcommerceFinance\Models\ReconciliationMatch;
use Modules\EcommerceFinance\Models\Settlement;
use Modules\EcommerceFinance\Models\SettlementLine;
class MatchSettlementLines extends JobShouldQueue
{
public function __construct(private readonly int $settlementId)
{
}
public function handle(): void
{
$settlement = Settlement::withoutGlobalScopes()->findOrFail($this->settlementId);
$lines = SettlementLine::withoutGlobalScopes()->where('settlement_id', $settlement->id)->get();
foreach ($lines as $line) {
$order = null;
$status = 'ignored';
$matchedAmount = '0';
if (! empty($line->external_order_id)) {
$order = Order::withoutGlobalScopes()
->where('store_id', $settlement->store_id)
->where('external_order_id', $line->external_order_id)
->first();
if (! $order) {
$status = 'unmatched';
} else {
$matchedAmount = $line->amount;
$status = $this->amountMatches($line->amount, $this->expectedAmount($order, $line->line_type))
? 'auto_matched'
: 'partial_matched';
}
}
ReconciliationMatch::withoutGlobalScopes()->updateOrCreate(
['settlement_line_id' => $line->id, 'order_id' => $order?->id],
[
'company_id' => $settlement->company_id,
'match_type' => $status === 'auto_matched' ? 'rule' : 'exception',
'matched_amount' => $matchedAmount,
'status' => $status,
'rule_version' => '1.0',
]
);
}
}
private function expectedAmount(Order $order, string $lineType): string
{
if ($lineType === 'refund') {
return $order->refunded_amount;
}
return bcsub(
bcadd($order->buyer_paid_amount, $order->platform_subsidy_amount, 6),
$order->refunded_amount,
6
);
}
private function amountMatches(string $actual, string $expected): bool
{
return bccomp(ltrim($actual, '-'), ltrim($expected, '-'), 6) === 0;
}
}

View File

@ -0,0 +1,295 @@
<?php
namespace Modules\EcommerceFinance\Jobs;
use App\Abstracts\JobShouldQueue;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Modules\EcommerceFinance\Models\ImportBatch;
use Modules\EcommerceFinance\Models\Order;
use Modules\EcommerceFinance\Models\OrderItem;
use Modules\EcommerceFinance\Models\Settlement;
use Modules\EcommerceFinance\Models\SettlementLine;
use RuntimeException;
use Throwable;
class ProcessImportBatch extends JobShouldQueue
{
public function __construct(private readonly int $batchId)
{
}
public function handle(): void
{
$batch = ImportBatch::withoutGlobalScopes()->findOrFail($this->batchId);
if (in_array($batch->status, ['completed', 'completed_with_errors'], true)) {
return;
}
$batch->update([
'status' => 'processing',
'started_at' => now(),
'error_message' => null,
]);
try {
$summary = $batch->source_type === 'orders'
? $this->importOrders($batch)
: $this->importSettlements($batch);
$batch->update([
'status' => empty($summary['errors']) ? 'completed' : 'completed_with_errors',
'row_count' => $summary['rows'],
'success_count' => $summary['success'],
'error_count' => count($summary['errors']),
'summary' => $summary,
'completed_at' => now(),
]);
foreach ($summary['settlement_ids'] ?? [] as $settlementId) {
$this->dispatch(new MatchSettlementLines($settlementId));
}
} catch (Throwable $exception) {
$batch->update([
'status' => 'failed',
'error_message' => $exception->getMessage(),
'completed_at' => now(),
]);
throw $exception;
}
}
private function importOrders(ImportBatch $batch): array
{
$summary = ['rows' => 0, 'success' => 0, 'errors' => []];
foreach ($this->rows($batch, ['external_order_id', 'external_line_id', 'order_status', 'item_name', 'quantity']) as $line => $row) {
++$summary['rows'];
try {
DB::transaction(function () use ($batch, $row) {
$order = Order::withoutGlobalScopes()->withTrashed()->updateOrCreate(
['store_id' => $batch->store_id, 'external_order_id' => $this->required($row, 'external_order_id')],
[
'company_id' => $batch->company_id,
'order_status' => $this->required($row, 'order_status'),
'ordered_at' => $this->date($row, 'ordered_at'),
'paid_at' => $this->date($row, 'paid_at'),
'shipped_at' => $this->date($row, 'shipped_at'),
'completed_at' => $this->date($row, 'completed_at'),
'currency_code' => strtoupper($this->value($row, 'currency_code') ?: 'CNY'),
'gross_amount' => $this->money($row, 'gross_amount'),
'buyer_paid_amount' => $this->money($row, 'buyer_paid_amount'),
'platform_subsidy_amount' => $this->money($row, 'platform_subsidy_amount'),
'merchant_subsidy_amount' => $this->money($row, 'merchant_subsidy_amount'),
'refunded_amount' => $this->money($row, 'refunded_amount'),
'payload' => $row,
'created_by' => $batch->created_by,
'created_from' => 'ecommerce-finance:import',
]
);
if ($order->trashed()) {
$order->restore();
}
$item = OrderItem::withoutGlobalScopes()->withTrashed()->updateOrCreate(
['order_id' => $order->id, 'external_line_id' => $this->required($row, 'external_line_id')],
[
'company_id' => $batch->company_id,
'sku_code' => $this->value($row, 'sku_code'),
'name' => $this->required($row, 'item_name'),
'quantity' => $this->number($row, 'quantity'),
'sale_amount' => $this->money($row, 'sale_amount'),
'discount_amount' => $this->money($row, 'discount_amount'),
'refund_amount' => $this->money($row, 'refund_amount'),
'created_by' => $batch->created_by,
'created_from' => 'ecommerce-finance:import',
]
);
if ($item->trashed()) {
$item->restore();
}
});
++$summary['success'];
} catch (Throwable $exception) {
$summary['errors'][] = ['line' => $line, 'message' => $exception->getMessage()];
}
}
return $summary;
}
private function importSettlements(ImportBatch $batch): array
{
$summary = ['rows' => 0, 'success' => 0, 'errors' => [], 'settlement_ids' => []];
foreach ($this->rows($batch, ['external_settlement_id', 'external_line_id', 'line_type', 'line_amount']) as $line => $row) {
++$summary['rows'];
try {
$settlementId = null;
DB::transaction(function () use ($batch, $row, &$settlementId) {
$settlement = Settlement::withoutGlobalScopes()->withTrashed()->updateOrCreate(
['store_id' => $batch->store_id, 'external_settlement_id' => $this->required($row, 'external_settlement_id')],
[
'company_id' => $batch->company_id,
'period_start' => $this->date($row, 'period_start'),
'period_end' => $this->date($row, 'period_end'),
'settled_at' => $this->date($row, 'settled_at'),
'currency_code' => strtoupper($this->value($row, 'currency_code') ?: 'CNY'),
'gross_amount' => $this->money($row, 'gross_amount'),
'fee_amount' => $this->money($row, 'fee_amount'),
'refund_amount' => $this->money($row, 'refund_amount'),
'net_amount' => $this->money($row, 'net_amount'),
'status' => 'imported',
'payload' => $row,
'created_by' => $batch->created_by,
'created_from' => 'ecommerce-finance:import',
]
);
if ($settlement->trashed()) {
$settlement->restore();
}
$item = SettlementLine::withoutGlobalScopes()->withTrashed()->updateOrCreate(
['settlement_id' => $settlement->id, 'external_line_id' => $this->required($row, 'external_line_id')],
[
'company_id' => $batch->company_id,
'line_type' => $this->required($row, 'line_type'),
'external_order_id' => $this->value($row, 'external_order_id'),
'amount' => $this->money($row, 'line_amount'),
'occurred_at' => $this->date($row, 'occurred_at'),
'payload' => $row,
'created_by' => $batch->created_by,
'created_from' => 'ecommerce-finance:import',
]
);
if ($item->trashed()) {
$item->restore();
}
$settlementId = $settlement->id;
});
$summary['settlement_ids'][] = $settlementId;
++$summary['success'];
} catch (Throwable $exception) {
$summary['errors'][] = ['line' => $line, 'message' => $exception->getMessage()];
}
}
$summary['settlement_ids'] = array_values(array_unique($summary['settlement_ids']));
return $summary;
}
private function rows(ImportBatch $batch, array $requiredHeaders): iterable
{
$stream = Storage::disk('uploads')->readStream($batch->source_file);
if (! is_resource($stream)) {
throw new RuntimeException('Unable to read the import source file.');
}
try {
$headers = fgetcsv($stream);
if ($headers === false) {
throw new RuntimeException('The import source file is empty.');
}
$headers = array_map(function ($header) {
return trim(preg_replace('/^\xEF\xBB\xBF/', '', (string) $header));
}, $headers);
$missingHeaders = array_diff($requiredHeaders, $headers);
if (! empty($missingHeaders)) {
throw new RuntimeException('Missing CSV columns: ' . implode(', ', $missingHeaders));
}
$line = 1;
while (($values = fgetcsv($stream)) !== false) {
++$line;
if ($values === [null] || $values === []) {
continue;
}
$values = array_pad($values, count($headers), null);
yield $line => array_combine($headers, array_slice($values, 0, count($headers)));
}
} finally {
fclose($stream);
}
}
private function required(array $row, string $field): string
{
$value = $this->value($row, $field);
if ($value === null || $value === '') {
throw new RuntimeException("Missing required value: {$field}");
}
return $value;
}
private function value(array $row, string $field): ?string
{
$value = $row[$field] ?? null;
return $value === null ? null : trim((string) $value);
}
private function money(array $row, string $field): string
{
$value = $this->value($row, $field);
if ($value === null || $value === '') {
return '0';
}
if (! is_numeric($value)) {
throw new RuntimeException("Invalid monetary value for {$field}");
}
return $value;
}
private function number(array $row, string $field): string
{
$value = $this->required($row, $field);
if (! is_numeric($value)) {
throw new RuntimeException("Invalid numeric value for {$field}");
}
return $value;
}
private function date(array $row, string $field): ?string
{
$value = $this->value($row, $field);
if ($value === null || $value === '') {
return null;
}
try {
return Carbon::parse($value)->toDateTimeString();
} catch (Throwable) {
throw new RuntimeException("Invalid datetime value for {$field}");
}
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace Modules\EcommerceFinance\Models;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Channel extends Model
{
protected $table = 'ec_channels';
protected $fillable = [
'company_id',
'code',
'name',
'type',
'enabled',
'created_by',
'created_from',
];
public function stores(): HasMany
{
return $this->hasMany(Store::class);
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace Modules\EcommerceFinance\Models;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ImportBatch extends Model
{
protected $table = 'ec_import_batches';
protected $fillable = [
'company_id',
'store_id',
'source_type',
'source_file',
'checksum',
'status',
'row_count',
'success_count',
'error_count',
'summary',
'error_message',
'started_at',
'completed_at',
'created_by',
'created_from',
];
protected $casts = [
'summary' => 'array',
'started_at' => 'datetime',
'completed_at' => 'datetime',
];
public function store(): BelongsTo
{
return $this->belongsTo(Store::class);
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace Modules\EcommerceFinance\Models;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Order extends Model
{
protected $table = 'ec_orders';
protected $fillable = [
'company_id',
'store_id',
'external_order_id',
'order_status',
'ordered_at',
'paid_at',
'shipped_at',
'completed_at',
'currency_code',
'gross_amount',
'buyer_paid_amount',
'platform_subsidy_amount',
'merchant_subsidy_amount',
'refunded_amount',
'payload',
'created_by',
'created_from',
];
protected $casts = [
'ordered_at' => 'datetime',
'paid_at' => 'datetime',
'shipped_at' => 'datetime',
'completed_at' => 'datetime',
'payload' => 'array',
];
public function store(): BelongsTo
{
return $this->belongsTo(Store::class);
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
public function events(): HasMany
{
return $this->hasMany(OrderEvent::class);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace Modules\EcommerceFinance\Models;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OrderEvent extends Model
{
protected $table = 'ec_order_events';
protected $fillable = [
'company_id',
'order_id',
'event_type',
'external_event_id',
'event_at',
'payload',
'created_by',
'created_from',
];
protected $casts = [
'event_at' => 'datetime',
'payload' => 'array',
];
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace Modules\EcommerceFinance\Models;
use App\Abstracts\Model;
use App\Models\Common\Item;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OrderItem extends Model
{
protected $table = 'ec_order_items';
protected $fillable = [
'company_id',
'order_id',
'item_id',
'external_line_id',
'sku_code',
'name',
'quantity',
'sale_amount',
'discount_amount',
'refund_amount',
'cost_amount',
'created_by',
'created_from',
];
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function item(): BelongsTo
{
return $this->belongsTo(Item::class);
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace Modules\EcommerceFinance\Models;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReconciliationMatch extends Model
{
protected $table = 'ec_reconciliation_matches';
protected $fillable = [
'company_id',
'settlement_line_id',
'order_id',
'match_type',
'matched_amount',
'status',
'rule_version',
'confirmed_by',
'confirmed_at',
'note',
];
protected $casts = [
'confirmed_at' => 'datetime',
];
public function settlementLine(): BelongsTo
{
return $this->belongsTo(SettlementLine::class);
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace Modules\EcommerceFinance\Models;
use App\Abstracts\Model;
use App\Models\Banking\Transaction;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Settlement extends Model
{
protected $table = 'ec_settlements';
protected $fillable = [
'company_id',
'store_id',
'transaction_id',
'external_settlement_id',
'period_start',
'period_end',
'settled_at',
'currency_code',
'gross_amount',
'fee_amount',
'refund_amount',
'net_amount',
'status',
'payload',
'created_by',
'created_from',
];
protected $casts = [
'period_start' => 'datetime',
'period_end' => 'datetime',
'settled_at' => 'datetime',
'payload' => 'array',
];
public function store(): BelongsTo
{
return $this->belongsTo(Store::class);
}
public function transaction(): BelongsTo
{
return $this->belongsTo(Transaction::class);
}
public function lines(): HasMany
{
return $this->hasMany(SettlementLine::class);
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace Modules\EcommerceFinance\Models;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class SettlementLine extends Model
{
protected $table = 'ec_settlement_lines';
protected $fillable = [
'company_id',
'settlement_id',
'line_type',
'external_line_id',
'external_order_id',
'amount',
'occurred_at',
'payload',
'created_by',
'created_from',
];
protected $casts = [
'occurred_at' => 'datetime',
'payload' => 'array',
];
public function settlement(): BelongsTo
{
return $this->belongsTo(Settlement::class);
}
public function matches(): HasMany
{
return $this->hasMany(ReconciliationMatch::class);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace Modules\EcommerceFinance\Models;
use App\Abstracts\Model;
use App\Models\Common\Item;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SkuMapping extends Model
{
protected $table = 'ec_sku_mappings';
protected $fillable = [
'company_id',
'store_id',
'item_id',
'external_sku_id',
'external_sku_code',
'external_sku_name',
'created_by',
'created_from',
];
public function store(): BelongsTo
{
return $this->belongsTo(Store::class);
}
public function item(): BelongsTo
{
return $this->belongsTo(Item::class);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace Modules\EcommerceFinance\Models;
use App\Abstracts\Model;
use App\Models\Banking\Account;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Store extends Model
{
protected $table = 'ec_stores';
protected $fillable = [
'company_id',
'channel_id',
'name',
'external_id',
'settlement_account_id',
'timezone',
'currency_code',
'enabled',
'created_by',
'created_from',
];
public function channel(): BelongsTo
{
return $this->belongsTo(Channel::class);
}
public function settlementAccount(): BelongsTo
{
return $this->belongsTo(Account::class, 'settlement_account_id');
}
public function importBatches(): HasMany
{
return $this->hasMany(ImportBatch::class);
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
public function settlements(): HasMany
{
return $this->hasMany(Settlement::class);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Modules\EcommerceFinance\Providers;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as Provider;
class Event extends Provider
{
public function shouldDiscoverEvents(): bool
{
return true;
}
protected function discoverEventsWithin(): array
{
return [__DIR__ . '/../Listeners'];
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace Modules\EcommerceFinance\Providers;
use Illuminate\Support\ServiceProvider as Provider;
class Main extends Provider
{
public function boot(): void
{
$this->loadTranslationsFrom(__DIR__ . '/../Resources/lang', 'ecommerce-finance');
$this->loadMigrationsFrom(__DIR__ . '/../Database/Migrations');
}
public function register(): void
{
if (app()->routesAreCached()) {
return;
}
foreach (['admin.php', 'api.php'] as $route) {
$this->loadRoutesFrom(__DIR__ . '/../Routes/' . $route);
}
}
}

View File

@ -0,0 +1,6 @@
<?php
return [
'name' => 'E-commerce Finance',
'description' => 'Reconcile marketplace settlements and calculate e-commerce profitability.',
];

View File

@ -0,0 +1,6 @@
<?php
return [
'name' => '电商财务',
'description' => '管理平台结算、差异对账与电商利润核算。',
];

View File

@ -0,0 +1,11 @@
<x-layouts.admin>
<x-slot name="title">{{ trans('ecommerce-finance::general.name') }}</x-slot>
<x-slot name="content">
<x-empty-page
page="module"
title="{{ trans('ecommerce-finance::general.name') }}"
description="{{ trans('ecommerce-finance::general.description') }}"
/>
</x-slot>
</x-layouts.admin>

View File

@ -0,0 +1,7 @@
<?php
use Illuminate\Support\Facades\Route;
Route::admin('ecommerce-finance', function () {
Route::view('/', 'ecommerce-finance::index')->name('index');
});

View File

@ -0,0 +1,11 @@
<?php
use Illuminate\Support\Facades\Route;
Route::api('ecommerce-finance', function () {
Route::post('import-files', 'ImportFiles@store')->name('import-files.store');
Route::apiResource('channels', 'Channels')->except(['destroy']);
Route::apiResource('stores', 'Stores')->except(['destroy']);
Route::apiResource('import-batches', 'ImportBatches')->only(['index', 'show', 'store']);
Route::get('reconciliation-exceptions', 'ReconciliationExceptions@index')->name('reconciliation-exceptions.index');
});

View File

@ -0,0 +1,140 @@
<?php
namespace Modules\EcommerceFinance\Tests\Feature;
use Modules\EcommerceFinance\Models\Channel;
use Modules\EcommerceFinance\Models\Store;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\UploadedFile;
use Tests\Feature\FeatureTestCase;
class ChannelsTest extends FeatureTestCase
{
public function test_it_uploads_a_private_csv_file_for_import(): void
{
$this->loginAs();
Storage::fake('uploads');
$file = UploadedFile::fake()->createWithContent('orders.csv', implode("\n", [
'external_order_id,external_line_id,order_status,item_name,quantity',
'order-001,line-001,completed,Wireless Mouse,1',
]));
$response = $this->withHeaders($this->apiHeaders())
->post(route('api.ecommerce-finance.import-files.store'), ['source_file' => $file])
->assertCreated()
->assertJsonStructure(['source_file', 'checksum']);
Storage::disk('uploads')->assertExists($response->json('source_file'));
$this->assertSame(hash_file('sha256', $file->getRealPath()), $response->json('checksum'));
}
public function test_it_creates_a_channel_for_the_current_company(): void
{
$request = [
'code' => 'taobao',
'name' => 'Taobao',
'type' => 'marketplace',
'enabled' => true,
];
$this->loginAs();
$this->withHeaders($this->apiHeaders())
->postJson(route('api.ecommerce-finance.channels.store'), $request)
->assertCreated()
->assertJsonPath('company_id', company_id())
->assertJsonPath('code', 'taobao');
$this->assertDatabaseHas('ec_channels', [
'company_id' => company_id(),
'code' => 'taobao',
'name' => 'Taobao',
]);
}
public function test_it_rejects_a_duplicate_import_batch(): void
{
$this->loginAs();
Storage::fake('uploads');
$channel = Channel::create([
'company_id' => company_id(),
'code' => 'jd',
'name' => 'JD',
'type' => 'marketplace',
]);
$store = Store::create([
'company_id' => company_id(),
'channel_id' => $channel->id,
'name' => 'JD Flagship Store',
'external_id' => 'jd-flagship',
]);
$sourceFile = 'ecommerce-finance/imports/jd-orders-20260718.csv';
Storage::disk('uploads')->put($sourceFile, implode("\n", [
'external_order_id,external_line_id,order_status,item_name,quantity,sale_amount,gross_amount,buyer_paid_amount,currency_code,completed_at',
'order-001,line-001,completed,Wireless Mouse,2,99.80,99.80,99.80,CNY,2026-07-18 10:00:00',
]));
$request = [
'store_id' => $store->id,
'source_type' => 'orders',
'source_file' => $sourceFile,
'checksum' => str_repeat('a', 64),
];
$this->withHeaders($this->apiHeaders())
->postJson(route('api.ecommerce-finance.import-batches.store'), $request)
->assertCreated()
->assertJsonPath('status', 'completed');
$this->assertDatabaseHas('ec_orders', [
'company_id' => company_id(),
'store_id' => $store->id,
'external_order_id' => 'order-001',
]);
$this->assertDatabaseHas('ec_order_items', [
'company_id' => company_id(),
'external_line_id' => 'line-001',
'name' => 'Wireless Mouse',
]);
$settlementFile = 'ecommerce-finance/imports/jd-settlement-20260718.csv';
Storage::disk('uploads')->put($settlementFile, implode("\n", [
'external_settlement_id,external_line_id,line_type,external_order_id,line_amount,currency_code,settled_at',
'settlement-001,settlement-line-001,order_payment,order-001,99.80,CNY,2026-07-19 10:00:00',
]));
$this->withHeaders($this->apiHeaders())
->postJson(route('api.ecommerce-finance.import-batches.store'), [
'store_id' => $store->id,
'source_type' => 'settlements',
'source_file' => $settlementFile,
'checksum' => str_repeat('b', 64),
])
->assertCreated()
->assertJsonPath('status', 'completed');
$this->assertDatabaseHas('ec_reconciliation_matches', [
'company_id' => company_id(),
'status' => 'auto_matched',
'matched_amount' => '99.8',
]);
$this->withExceptionHandling();
$this->withHeaders($this->apiHeaders())
->postJson(route('api.ecommerce-finance.import-batches.store'), $request)
->assertBadRequest();
}
private function apiHeaders(): array
{
return [
'Authorization' => 'Basic ' . base64_encode('test@company.com:123456'),
];
}
}

View File

@ -0,0 +1,18 @@
{
"alias": "ecommerce-finance",
"icon": "shopping-cart",
"version": "0.1.0",
"active": 1,
"providers": [
"Modules\\EcommerceFinance\\Providers\\Event",
"Modules\\EcommerceFinance\\Providers\\Main"
],
"aliases": {},
"files": [],
"requires": [],
"reports": [],
"widgets": [],
"settings": [],
"extra-modules": {},
"routes": []
}

View File

@ -0,0 +1,50 @@
name: Tests
on:
schedule:
- cron: '0 0 * * 0'
workflow_dispatch:
jobs:
tests:
name: PHP ${{ matrix.php }}
runs-on: ubuntu-latest
strategy:
matrix:
php: ['7.3', '7.4']
steps:
- name: Checkout Akaunting
uses: actions/checkout@v2
with:
repository: akaunting/akaunting
- name: Checkout Offline Payments module
uses: actions/checkout@v2
with:
path: modules/OfflinePayments
- name: Cache Composer
uses: actions/cache@v1
with:
path: ~/.composer/cache/files
key: php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }}
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: bcmath, ctype, dom, fileinfo, intl, gd, json, mbstring, pdo, pdo_sqlite, openssl, sqlite, xml, zip
coverage: none
- name: Copy .env
run: cp .env.testing .env
- name: Install Composer
run: cd modules/OfflinePayments ; composer test ; cd ../.. ; composer test
- name: Execute tests
run: php artisan test --parallel

View File

@ -0,0 +1,37 @@
name: Translations
on:
schedule:
- cron: '0 0 * * 0'
workflow_dispatch:
jobs:
sync:
name: Sync
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Sync with Crowdin
uses: crowdin/github-action@master
with:
upload_sources: true
upload_translations: true
download_translations: true
skip_untranslated_files: true
localization_branch_name: 'translations'
commit_message: 'new crowdin translations'
pull_request_title: 'New Crowdin translations'
pull_request_body: 'https://crowdin.com/project/akaunting-apps'
pull_request_labels: 'Translation'
config: 'crowdin.yml'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_APPS_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

7
modules/OfflinePayments/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
/.idea
/.history
/.vscode
/.vagrant
/node_modules
/storage/*.key
/vendor

View File

@ -0,0 +1,89 @@
<?php
namespace Modules\OfflinePayments\Http\Controllers;
use App\Abstracts\Http\PaymentController;
use \App\Events\Document\PaymentReceived;
use App\Http\Requests\Portal\InvoicePayment as PaymentRequest;
use App\Models\Document\Document;
use App\Utilities\Modules;
use Illuminate\Http\Request;
class Payment extends PaymentController
{
public $alias = 'offline-payments';
public $type = 'redirect';
public function show(Document $invoice, PaymentRequest $request, $cards = [])
{
$setting = [];
$payment_methods = json_decode(setting('offline-payments.methods'), true);
foreach ($payment_methods as $payment_method) {
if ($payment_method['code'] == $request['payment_method']) {
$setting = $payment_method;
break;
}
}
if (empty($setting)) {
Modules::clearPaymentMethodsCache();
return response()->json([
'code' => '',
'name' => 'N/A',
'description' => '',
'redirect' => redirect()->back()->getTargetUrl(),
'html' => '',
]);
}
$confirm_url = $this->getConfirmUrl($invoice);
$html = view('offline-payments::show', compact('setting', 'invoice', 'confirm_url'))->render();
return response()->json([
'code' => $setting['code'],
'name' => $setting['name'],
'description' => $setting['description'],
'redirect' => false,
'html' => $html,
]);
}
public function confirm(Document $invoice, Request $request)
{
try {
event(new PaymentReceived($invoice, $request));
$message = trans('messages.success.added', ['type' => trans_choice('general.payments', 1)]);
flash($message)->success();
$response = [
'success' => true,
'error' => false,
'message' => $message,
'data' => false,
'redirect' => $this->getFinishUrl($invoice),
];
} catch(\Exception $e) {
$message = $e->getMessage();
flash($message)->error()->important();
$response = [
'success' => false,
'error' => true,
'message' => $message,
'data' => false,
'redirect' => $this->getInvoiceUrl($invoice),
];
}
return response()->json($response);
}
}

View File

@ -0,0 +1,120 @@
<?php
namespace Modules\OfflinePayments\Http\Controllers;
use App\Abstracts\Http\Controller;
use Illuminate\Http\Response;
use Modules\OfflinePayments\Http\Requests\Setting as Request;
use Modules\OfflinePayments\Http\Requests\SettingGet as GRequest;
use Modules\OfflinePayments\Http\Requests\SettingDelete as DRequest;
use Modules\OfflinePayments\Jobs\CreatePaymentMethod;
use Modules\OfflinePayments\Jobs\DeletePaymentMethod;
use Modules\OfflinePayments\Jobs\UpdatePaymentMethod;
class Settings extends Controller
{
/**
* Show the form for editing the specified resource.
*
* @return Response
*/
public function edit()
{
$methods = json_decode(setting('offline-payments.methods'));
return view('offline-payments::edit', compact('methods'));
}
/**
* Update the specified resource in storage.
*
* @param Request $request
*
* @return Response
*/
public function update(Request $request)
{
$code_exists = false;
$methods = json_decode(setting('offline-payments.methods'), true);
if (in_array($request->get('update_code'), array_column($methods, 'code'))) {
$code_exists = true;
}
if (!empty($request->get('update_code')) && $code_exists == true) {
$payment_method = $this->dispatch(new UpdatePaymentMethod($request));
$message = trans('messages.success.updated', ['type' => $payment_method['name']]);
} else {
$payment_method = $this->dispatch(new CreatePaymentMethod($request));
$message = trans('messages.success.added', ['type' => $payment_method['name']]);
}
flash($message)->success();
return response()->json([
'success' => true,
'error' => false,
'message' => $message,
'redirect' => route('offline-payments.settings.edit'),
]);
}
/**
* Remove the specified resource from storage.
*
* @param GRequest $request
*
* @return Response
*/
public function get(GRequest $request)
{
$data = [];
$code = $request->get('code');
$methods = json_decode(setting('offline-payments.methods'), true);
foreach ($methods as $key => $method) {
if ($method['code'] != $code) {
continue;
}
$method['title'] = trans('offline-payments::offline-payments.edit', ['method' => $method['name']]);
$method['update_code'] = $code;
$code = explode('.', $method['code']);
$method['code'] = $code[1];
$data = $method;
break;
}
return response()->json([
'errors' => false,
'success' => true,
'data' => $data,
]);
}
/**
* Remove the specified resource from storage.
*
* @param DRequest $request
*
* @return Response
*/
public function destroy(DRequest $request)
{
$response = $this->ajaxDispatch(new DeletePaymentMethod($request));
if ($response['success']) {
$response['message'] = trans('messages.success.deleted', ['type' => $response['data']['name']]);
}
return response()->json($response);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Modules\OfflinePayments\Http\Requests;
use App\Abstracts\Http\FormRequest as Request;
class Setting extends Request
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'name' => 'required|string',
];
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Modules\OfflinePayments\Http\Requests;
use App\Abstracts\Http\FormRequest as Request;
class SettingDelete extends Request
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'code' => 'required|string',
];
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Modules\OfflinePayments\Http\Requests;
use App\Abstracts\Http\FormRequest as Request;
class SettingGet extends Request
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'code' => 'required|string',
];
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Modules\OfflinePayments\Http\Requests;
use App\Abstracts\Http\FormRequest as Request;
class Show extends Request
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'payment_method' => 'required|string',
];
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace Modules\OfflinePayments\Jobs;
use App\Abstracts\Job;
use App\Utilities\Modules;
use Illuminate\Support\Str;
class CreatePaymentMethod extends Job
{
protected $request;
/**
* Create a new job instance.
*
* @param $request
*/
public function __construct($request)
{
$this->request = $this->getRequestInstance($request);
}
/**
* Execute the job.
*
* @return array
*/
public function handle()
{
$methods = json_decode(setting('offline-payments.methods'), true);
$code = 'offline-payments.' . Str::slug($this->request->get('name'), '_') . '.' . (count($methods) + 1);
foreach ($methods as $method) {
if (Str::contains($method['code'], 'offline-payments.' . Str::slug($this->request->get('name'), '_') . '.')) {
return $method;
}
}
$payment_method = [
'code' => $code,
'name' => $this->request->get('name'),
'customer' => $this->request->get('customer'),
'order' => $this->request->get('order'),
'description' => $this->request->get('description'),
];
$methods[] = $payment_method;
setting()->set('offline-payments.methods', json_encode($methods));
setting()->save();
Modules::clearPaymentMethodsCache();
return $payment_method;
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace Modules\OfflinePayments\Jobs;
use App\Abstracts\Job;
use App\Utilities\Modules;
use App\Models\Banking\Transaction;
use App\Models\Banking\Transfer;
class DeletePaymentMethod extends Job
{
protected $request;
/**
* Create a new job instance.
*
* @param $request
*/
public function __construct($request)
{
$this->request = $this->getRequestInstance($request);
}
/**
* Execute the job.
*
* @return array
*/
public function handle()
{
$this->authorize();
$methods = json_decode(setting('offline-payments.methods'), true);
$payment_method = [];
$code = $this->request->get('code');
foreach ($methods as $key => $method) {
if ($method['code'] != $code) {
continue;
}
$payment_method = $methods[$key];
unset($methods[$key]);
}
setting()->set('offline-payments.methods', json_encode($methods));
setting()->save();
Modules::clearPaymentMethodsCache();
return $payment_method;
}
/**
* Determine if this action is applicable.
*
* @return void
*/
public function authorize()
{
if ($relationships = $this->getRelationships()) {
$methods = json_decode(setting('offline-payments.methods'), true);
$method = $methods[array_search($this->request->get('code'), array_column($methods, 'code'))];
$message = trans('messages.warning.deleted', ['name' => $method['name'], 'text' => implode(', ', $relationships)]);
throw new \Exception($message);
}
}
public function getRelationships()
{
$counter = [];
if ($transaction_count = Transaction::where('payment_method', $this->request->get('code'))->count()) {
$counter[] = $transaction_count . ' ' . strtolower(trans_choice('general.transactions', ($transaction_count > 1) ? 2 : 1));
}
return $counter;
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace Modules\OfflinePayments\Jobs;
use App\Abstracts\Job;
use App\Utilities\Modules;
class UpdatePaymentMethod extends Job
{
protected $request;
/**
* Create a new job instance.
*
* @param $request
*/
public function __construct($request)
{
$this->request = $this->getRequestInstance($request);
}
/**
* Execute the job.
*
* @return array
*/
public function handle()
{
$methods = json_decode(setting('offline-payments.methods'), true);
$payment_method = [];
$code = $this->request->get('update_code');
foreach ($methods as $key => $method) {
if ($method['code'] != $code) {
continue;
}
$payment_method = [
'code' => $code,
'name' => $this->request->get('name'),
'customer' => $this->request->get('customer'),
'order' => $this->request->get('order'),
'description' => $this->request->get('description'),
];
$methods[$key] = $payment_method;
}
setting()->set('offline-payments.methods', json_encode($methods));
setting()->save();
Modules::clearPaymentMethodsCache();
return $payment_method;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Modules\OfflinePayments\Listeners;
use App\Events\Auth\LandingPageShowing as Event;
class AddLandingPage
{
/**
* Handle the event.
*
* @param Event $event
* @return void
*/
public function handle(Event $event)
{
$event->user->landing_pages['offline-payments.settings.edit'] = trans('offline-payments::general.name');
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace Modules\OfflinePayments\Listeners;
use App\Events\Module\Installed as Event;
use App\Traits\Permissions;
class FinishInstallation
{
use Permissions;
public $alias = 'offline-payments';
/**
* Handle the event.
*
* @param Event $event
* @return void
*/
public function handle(Event $event)
{
if ($event->alias != $this->alias) {
return;
}
$this->updatePermissions();
}
protected function updatePermissions()
{
// c=create, r=read, u=update, d=delete
$this->attachPermissionsToAdminRoles([
$this->alias . '-settings' => 'r,u,d',
]);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Modules\OfflinePayments\Listeners;
use App\Events\Module\PaymentMethodShowing as Event;
class ShowAsPaymentMethod
{
/**
* Handle the event.
*
* @param Event $event
* @return void
*/
public function handle(Event $event)
{
$methods = json_decode(setting('offline-payments.methods'), true);
if (empty($methods)) {
return;
}
foreach ($methods as $method) {
$event->modules->payment_methods[] = $method;
}
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace Modules\OfflinePayments\Listeners;
use App\Events\Menu\SettingsCreated as Event;
use App\Traits\Modules;
use App\Traits\Permissions;
class ShowInSettingsMenu
{
use Modules, Permissions;
/**
* Handle the event.
*
* @param Event $event
* @return void
*/
public function handle(Event $event)
{
if (!$this->moduleIsEnabled('offline-payments')) {
return;
}
$title = trans('offline-payments::general.name');
if ($this->canAccessMenuItem($title, 'read-offline-payments-settings')) {
$event->menu->route('offline-payments.settings.edit', $title, [], 100, ['icon' => 'credit_card', 'search_keywords' => trans('offline-payments::general.description')]);
}
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace Modules\OfflinePayments\Listeners\Update;
use App\Abstracts\Listeners\Update as Listener;
use App\Events\Install\UpdateFinished;
use Illuminate\Support\Facades\File;
class Version300 extends Listener
{
const ALIAS = 'offline-payments';
const VERSION = '3.0.0';
/**
* Handle the event.
*
* @param $event
* @return void
*/
public function handle(UpdateFinished $event)
{
if ($this->skipThisUpdate($event)) {
return;
}
$this->deleteOldFiles();
}
protected function deleteOldFiles()
{
File::delete(base_path('modules/OfflinePayments/Listeners/ShowInSettingsPage.php'));
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Modules\OfflinePayments\Providers;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as Provider;
class Event extends Provider
{
/**
* Determine if events and listeners should be automatically discovered.
*
* @return bool
*/
public function shouldDiscoverEvents()
{
return true;
}
/**
* Get the listener directories that should be used to discover events.
*
* @return array
*/
protected function discoverEventsWithin()
{
return [
__DIR__ . '/../Listeners',
];
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace Modules\OfflinePayments\Providers;
use Illuminate\Support\ServiceProvider as Provider;
class Main extends Provider
{
/**
* Boot the application events.
*
* @return void
*/
public function boot()
{
$this->loadTranslations();
$this->loadViews();
}
/**
* Register the service provider.
*
* @return void
*/
public function register()
{
$this->loadRoutes();
}
/**
* Load views.
*
* @return void
*/
public function loadViews()
{
$this->loadViewsFrom(__DIR__ . '/../Resources/views', 'offline-payments');
}
/**
* Load translations.
*
* @return void
*/
public function loadTranslations()
{
$this->loadTranslationsFrom(__DIR__ . '/../Resources/lang', 'offline-payments');
}
/**
* Load routes.
*
* @return void
*/
public function loadRoutes()
{
if (app()->routesAreCached()) {
return;
}
$routes = [
'admin.php',
'portal.php',
'signed.php',
];
foreach ($routes as $route) {
$this->loadRoutesFrom(__DIR__ . '/../Routes/' . $route);
}
}
/**
* Get the services provided by the provider.
*
* @return array
*/
public function provides()
{
return [];
}
}

View File

@ -0,0 +1,11 @@
# Offline Payments app for Akaunting
[![Tests](https://github.com/akaunting/module-offline-payments/workflows/Tests/badge.svg?label=tests)](https://github.com/akaunting/module-offline-payments/actions)
## Tests
The workflow runs both [Akaunting](https://github.com/akaunting/akaunting/tree/master/tests) and module test suites. They're configured to run once per week and triggered manually. Therefore, **before** publishing a new release, run the workflow [manually](https://github.com/akaunting/module-offline-payments/actions?query=workflow%3ATests) and make sure it passes.
## Translations
[Crowdin](https://crowdin.com/project/akaunting-apps) is the home of translators and it's synced (download & upload) with GitHub. The workflow is configured to run once per week and triggered manually. Therefore, **before** publishing a new release, run the workflow [manually](https://github.com/akaunting/module-offline-payments/actions?query=workflow%3ATranslations) and merge the automatically created PR, if available. Finally, all language files must be listed in the [config](https://github.com/akaunting/module-offline-payments/blob/master/crowdin.yml) file.

View File

@ -0,0 +1,120 @@
/**
* First we will load all of this project's JavaScript dependencies which
* includes Vue and other libraries. It is a great starting point when
* building robust, powerful web applications using Vue and Laravel.
*/
require('./../../../../../resources/assets/js/bootstrap');
import Vue from 'vue';
import Global from './../../../../../resources/assets/js/mixins/global';
import Form from './../../../../../resources/assets/js/plugins/form';
import DashboardPlugin from './../../../../../resources/assets/js/plugins/dashboard-plugin';
// plugin setup
Vue.use(DashboardPlugin);
const app = new Vue({
el: '#app',
mixins: [
Global
],
data() {
return {
form: new Form('offline-payment'),
update_code: null,
form_loading: '',
}
},
methods:{
onEdit(event) {
axios.post(url + '/offline-payments/settings/get', {
code: event
})
.then(response => {
this.form.name = response.data.data.name;
this.form.code = response.data.data.code;
this.form.customer = response.data.data.customer;
this.form.order = response.data.data.order;
this.form.description = response.data.data.description;
this.form.update_code = response.data.data.update_code;
this.update_code = response.data.data.update_code;
this.form.loading = false;
this.form_loading = '';
})
.catch(error => {
this.form.loading = false;
this.form_loading = '';
});
},
// Actions > Delete
confirmDelete(code, title, message, button_cancel, button_delete) {
let confirm = {
code: code,
url: url,
title: title,
message: message,
button_cancel: button_cancel,
button_delete: button_delete,
show: true
};
this.component = Vue.component('add-new-component', (resolve, reject) => {
resolve({
template : '<div id="dynamic-delete-component"><akaunting-modal v-if="confirm.show" :show="confirm.show" :title="confirm.title" :message="confirm.message" :button_cancel="confirm.button_cancel" :button_delete="confirm.button_delete" @confirm="onDelete" @cancel="cancelDelete"></akaunting-modal></div>',
mixins: [
Global
],
data: function () {
return {
confirm: confirm,
}
},
methods: {
// Delete action post
async onDelete() {
Promise.resolve(axios({
method: 'DELETE',
url: url + '/offline-payments/settings/delete',
data: {
code: this.confirm.code
}
})).then(response => {
var type = (response.data.success) ? 'success' : 'warning';
if (response.data.success) {
document.getElementById('method-' + this.confirm.code).remove();
}
this.confirm.show = false;
this.$notify({
message: response.data.message,
timeout: 5000,
icon: 'info',
type
});
})
.catch(error => {
this.success = false;
});
},
// Close modal empty default value
cancelDelete() {
this.confirm.show = false;
},
}
})
});
}
}
});

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,21 @@
<?php
return [
'name' => 'المدفوعات بدون الإتصال بالأنترنت',
'description' => 'إنشاء خيارات دفع غير محدودة لاستخدام المسؤول',
'add_new' => 'إضافة جديد',
'edit' => 'تعديل: :method',
'form' => [
'code' => 'الكود',
'customer' => 'إظهار للعميل',
'order' => 'طلب'
],
'methods' => 'طريقة | طرق',
'payment_gateways' => 'طرق الدفع بدون أنترنت',
];

View File

@ -0,0 +1,21 @@
<?php
return [
'name' => 'Pagaments fora de línia',
'description' => 'Crea opcions il·limitades de configuració de pagaments per que l\'administrador els utilitzi',
'add_new' => 'Afegeix-ne un de nou',
'edit' => 'Edita :method',
'form' => [
'code' => 'Codi',
'customer' => 'Mostra a l\'usuari',
'order' => 'Comanda'
],
'methods' => 'Mètode|Mètodes',
'payment_gateways' => 'Mètodes de pagament fora de línia',
];

View File

@ -0,0 +1,21 @@
<?php
return [
'name' => 'Offline betalinger',
'description' => 'Opret ubegrænsede betalingsmuligheder til admin-brug',
'add_new' => 'Tilføj ny',
'edit' => 'Rediger: :method',
'form' => [
'code' => 'Kode',
'customer' => 'Vis til kunde',
'order' => 'Rækkefølge'
],
'methods' => 'Metode|Metoder',
'payment_gateways' => 'Offline betalingsmetoder',
];

View File

@ -0,0 +1,21 @@
<?php
return [
'name' => 'Offline-Zahlungen',
'description' => 'Erstelle unbegrenzte Zahlungsoptionen für Adminnutzung',
'add_new' => 'Neu hinzufügen',
'edit' => 'Bearbeiten: :method',
'form' => [
'code' => 'Code',
'customer' => 'Dem Kunden zeigen',
'order' => 'Bestellung'
],
'methods' => 'Methode|Methoden',
'payment_gateways' => 'Offline-Zahlungen-Methoden',
];

View File

@ -0,0 +1,22 @@
<?php
return [
'name' => 'Offline Payments',
'description' => 'Create unlimited payment options for admin usage',
'add_new' => 'Add New',
'edit' => 'Edit: :method',
'form' => [
'code' => 'Code',
'customer' => 'Show to Customer',
'order' => 'Order'
],
'methods' => 'Method|Methods',
'payment_gateways' => 'Offline Payment Methods',
'gateways_description' => 'Manage All Offline Payment Methods',
];

View File

@ -0,0 +1,21 @@
<?php
return [
'name' => 'Pagos sin conexión',
'description' => 'Crear opciones de pago ilimitadas para uso del administrador',
'add_new' => 'Añadir Nuevo',
'edit' => 'Editar: :method',
'form' => [
'code' => 'Código',
'customer' => 'Mostrar Cliente',
'order' => 'Pedido'
],
'methods' => 'Método|Métodos',
'payment_gateways' => 'Asignación de Medios de Pago',
];

View File

@ -0,0 +1,21 @@
<?php
return [
'name' => 'پرداخت های آفلاین',
'description' => 'ساخت گزینه پرداخت نامحدود برای استفاده مدیر',
'add_new' => 'افزودن جدید',
'edit' => 'ویرایش: :method',
'form' => [
'code' => 'کد',
'customer' => 'نمایش به مشتری',
'order' => 'سفارش'
],
'methods' => 'روش|روش ها',
'payment_gateways' => 'روش های پرداخت آفلاین',
];

View File

@ -0,0 +1,21 @@
<?php
return [
'name' => 'Paiements hors-ligne',
'description' => 'Créez un nombre illimité d\'options de paiement à usage administratif',
'add_new' => 'Ajouter un nouveau',
'edit' => 'Modifier : :method',
'form' => [
'code' => 'Code',
'customer' => 'Montrer au client',
'order' => 'Ordre'
],
'methods' => 'Méthode|Méthodes',
'payment_gateways' => 'Méthodes de paiement hors ligne',
];

View File

@ -0,0 +1,21 @@
<?php
return [
'name' => 'Pembayaran Offline',
'description' => 'Buat opsi pembayaran tak terbatas untuk admin gunakan',
'add_new' => 'Tambah Baru',
'edit' => 'Edit:: metode',
'form' => [
'code' => 'Kode',
'customer' => 'Tunjukkan kepada Pelanggan',
'order' => 'Pesanan'
],
'methods' => 'Metode|Metode',
'payment_gateways' => 'Metode Pembayaran Offline',
];

View File

@ -0,0 +1,21 @@
<?php
return [
'name' => 'Pagamenti offline',
'description' => 'Crea opzioni di pagamento illimitate per l\'utilizzo dell\'amministratore',
'add_new' => 'Aggiungi nuovo',
'edit' => 'Modifica: :method',
'form' => [
'code' => 'Codice',
'customer' => 'Mostra al Cliente',
'order' => 'Ordine'
],
'methods' => 'Metodi',
'payment_gateways' => 'Metodi di pagamento offline',
];

View File

@ -0,0 +1,21 @@
<?php
return [
'name' => 'オフライン支払い',
'description' => '管理者が使用するための無制限の支払いオプションを作成する',
'add_new' => '新しく追加する',
'edit' => 'エディット: :方法',
'form' => [
'code' => 'コード',
'customer' => '顧客に示す',
'order' => 'オーダー'
],
'methods' => '方法|方法',
'payment_gateways' => 'オフライン支払い方法',
];

View File

@ -0,0 +1,21 @@
<?php
return [
'name' => 'Offline betalinger',
'description' => 'Lag ubegrensede betalingsalternativer for administrasjonsbruk',
'add_new' => 'Legg til ny',
'edit' => 'Rediger:: method',
'form' => [
'code' => 'Kode',
'customer' => 'Vis til kunde',
'order' => 'Rekkefølge'
],
'methods' => 'Metode|Metoder',
'payment_gateways' => 'Offline betalingsmetoder',
];

View File

@ -0,0 +1,21 @@
<?php
return [
'name' => 'Pagamentos Offline',
'description' => 'Crie opções de pagamento ilimitadas para uso do administrador',
'add_new' => 'Adicionar Novo',
'edit' => 'Editar: :method',
'form' => [
'code' => 'Código',
'customer' => 'Mostrar ao Cliente',
'order' => 'Ordem'
],
'methods' => 'Método|Métodos',
'payment_gateways' => 'Métodos de Pagamento Offline',
];

View File

@ -0,0 +1,21 @@
<?php
return [
'name' => 'Offline Payments',
'description' => 'Krijoni mundësi të pakufizuara pagese për përdorim të administratorit',
'add_new' => 'Shto të Re',
'edit' => 'Redaktoni: :method',
'form' => [
'code' => 'Kodi',
'customer' => 'Trego tek Klienti',
'order' => 'Porosi'
],
'methods' => 'Metodë|Metodat',
'payment_gateways' => 'Metodat e Pagesës Offline',
];

View File

@ -0,0 +1,21 @@
<?php
return [
'name' => 'Оффлайн платежі',
'description' => 'Створити необмежену кількість варіантів платежів для використання адміна',
'add_new' => 'Добавити нове',
'edit' => 'Редагувати: :method',
'form' => [
'code' => 'Код',
'customer' => 'Показати Клієнту',
'order' => 'Замовлення'
],
'methods' => 'Метод|Методи',
'payment_gateways' => 'Способи Оффлайн Платежів',
];

View File

@ -0,0 +1,7 @@
<h2>{{ $setting['name'] }}</h2>
@if ($setting['description'])
<div class="well well-sm mt-2 blockquote">
{{ $setting['description'] }}
</div>
@endif

View File

@ -0,0 +1,124 @@
<x-layouts.admin>
<x-slot name="title">{{ trans('offline-payments::general.name') }}</x-slot>
<x-slot name="favorite"
title="{{ trans('offline-payments::general.name') }}"
icon="credit_card"
route="offline-payments.settings.edit"
></x-slot>
<x-slot name="content">
<x-show.container>
<x-show.content class="flex flex-col-reverse lg:flex-row mt-5 sm:mt-12 gap-y-4" override="class">
<x-show.content.left>
<x-form id="offline-payment" method="POST" route="offline-payments.settings.update">
<x-form.section>
<x-slot name="head">
<x-form.section.head title="{{ trans('general.add_new') }}" description="{{ trans('offline-payments::general.description') }}" />
</x-slot>
<x-slot name="body">
<x-form.group.text name="name" label="{{ trans('general.name') }}" form-group-class="sm:col-span-6" />
<x-form.group.text name="order" label="{{ trans('offline-payments::general.form.order') }}" form-group-class="sm:col-span-6" not-required />
<x-form.group.textarea name="description" label="{{ trans('general.description') }}" form-group-class="sm:col-span-6" row="3" enable-v-model="true" not-required />
<x-form.group.toggle name="customer" label="{{ trans('offline-payments::general.form.customer') }}" :value="false" />
<x-form.input.hidden name="update_code" />
</x-slot>
<x-slot name="foot">
<x-form.buttons :cancel="url()->previous()" />
</x-slot>
</x-form.section>
</x-form>
</x-show.content.left>
<x-show.content.right>
<x-form.section.head title="{{ trans('offline-payments::general.payment_gateways') }}" description="{{ trans('offline-payments::general.gateways_description') }}" />
<x-table>
<x-table.thead>
<x-table.tr class="flex items-center px-1">
<x-table.th class="w-3/12 sm:w-3/12">
{{ trans('general.name') }}
</x-table.th>
<x-table.th class="w-4/12 sm:w-5/12">
{{ trans('general.description') }}
</x-table.th>
<x-table.th class="w-2/12 sm:w-2/12">
{{ trans('offline-payments::general.form.order') }}
</x-table.th>
<x-table.th class="w-3/12 sm:w-2/12">
{{ trans('general.actions') }}
</x-table.th>
</x-table.tr>
</x-table.thead>
<x-table.tbody>
@foreach($methods as $item)
<x-table.tr id="method-{{ $item->code }}">
<x-table.th class="w-3/12 sm:w-3/12">
{{ $item->name }}
</x-table.th>
<x-table.th class="w-4/12 sm:w-5/12">
{{ $item->description ?? trans('general.na') }}
</x-table.th>
<x-table.th class="w-2/12 sm:w-2/12">
{{ $item->order }}
</x-table.th>
<x-table.th class="w-3/12 sm:w-2/12">
<div class="ltr:right-8 rtl:left-8 flex items-center">
@can('update-offline-payments-settings')
<x-button
type="button"
id="edit-{{ $item->code }}"
data-code="{{ $item->code }}"
class="relative bg-white hover:bg-gray-100 border py-0.5 px-1 cursor-pointer index-actions group"
override="class"
@click="onEdit('{{ $item->code }}')">
<span class="material-icons-outlined text-purple text-lg">edit</span>
<div class="inline-block absolute invisible z-20 py-1 px-2 text-sm font-medium text-gray-900 bg-white rounded-lg border border-gray-200 shadow-sm opacity-0 tooltip-content -top-10 -left-2" data-tooltip-placement="top">
<span>{{ trans('general.edit') }}</span>
<div class="absolute w-2 h-2 -bottom-1 before:content-[' '] before:absolute before:w-2 before:h-2 before:bg-white before:border-gray-200 before:transform before:rotate-45 before:border before:border-t-0 before:border-l-0" data-popper-arrow></div>
</div>
</x-button>
@endcan
@can('delete-offline-payments-settings')
<x-button
type="button"
class="relative bg-white hover:bg-gray-100 border py-0.5 px-1 cursor-pointer index-actions"
override="class"
id="delete-{{ $item->code }}"
data-code="{{ $item->code }}"
v-bind:disabled="update_code === '{{ $item->code }}'"
@click="confirmDelete('{{ $item->code }}', '{{ trans('general.delete') }}', '{{ trans('general.delete_confirm', ['name' => '<strong>' . $item->name . '</strong>', 'type' => mb_strtolower(trans('offline-payments::general.name'))]) }}', '{{ trans('general.cancel') }}', '{{ trans('general.delete') }}')">
<span class="material-icons-outlined text-purple text-lg">delete</span>
<div class="inline-block absolute invisible z-20 py-1 px-2 text-sm font-medium text-gray-900 bg-white rounded-lg border border-gray-200 shadow-sm opacity-0 tooltip-content -top-10 -left-2" data-tooltip-placement="top">
<span>{{ trans('general.delete') }}</span>
<div class="absolute w-2 h-2 -bottom-1 before:content-[' '] before:absolute before:w-2 before:h-2 before:bg-white before:border-gray-200 before:transform before:rotate-45 before:border before:border-t-0 before:border-l-0" data-popper-arrow></div>
</div>
</x-button>
@endcan
</div>
</x-table.th>
</x-table.tr>
@endforeach
</x-table.tbody>
</x-table>
</x-show.content.right>
</x-show.content>
</x-show.container>
</x-slot>
<x-script alias="offline-payments" file="offline-payments" />
</x-layouts.admin>

View File

@ -0,0 +1,36 @@
<div>
<div>
@if (!empty($setting['name']))
<h2>{{ $setting['name'] }}</h2>
@endif
@if (!empty($setting['description']))
<div class="well well-sm mt-2 blockquote">{{ $setting['description'] }}</div>
@endif
</div>
<br>
<div class="buttons">
<div class="pull-right">
<x-form id="redirect-form" method="POST" :url="$confirm_url">
<x-button
id="button-confirm"
type="submit"
@click="onRedirectConfirm"
class="relative flex items-center justify-center bg-green hover:bg-green-700 text-white px-6 py-1.5 text-base rounded-lg disabled:bg-green-100"
::disabled="form.loading"
override="class"
>
<i v-if="form.loading" class="submit-spin absolute w-3 h-3 rounded-full left-0 right-0 -top-3.5 m-auto"></i>
<span :class="[{'opacity-0': form.loading}]">
{{ trans('general.confirm') }}
</span>
</x-button>
<x-form.input.hidden name="payment_method" :value="$setting['code']" />
<x-form.input.hidden name="type" value="income" />
</x-form>
</div>
</div>
</div>

View File

@ -0,0 +1,18 @@
<?php
use Illuminate\Support\Facades\Route;
/**
* 'admin' middleware and 'offline-payments' prefix applied to all routes (including names)
*
* @see \App\Providers\Route::register
*/
Route::admin('offline-payments', function () {
Route::group(['prefix' => 'settings', 'as' => 'settings.'], function () {
Route::get('/', 'Settings@edit')->name('edit');
Route::post('/', 'Settings@update')->name('update');
Route::post('get', 'Settings@get')->name('get');
Route::delete('delete', 'Settings@destroy')->name('delete');
});
});

View File

@ -0,0 +1,14 @@
<?php
use Illuminate\Support\Facades\Route;
/**
* 'portal' middleware and 'portal/offline-payments' prefix applied to all routes (including names)
*
* @see \App\Providers\Route::register
*/
Route::portal('offline-payments', function () {
Route::get('invoices/{invoice}', 'Payment@show')->name('invoices.show');
Route::post('invoices/{invoice}/confirm', 'Payment@confirm')->name('invoices.confirm');
});

View File

@ -0,0 +1,14 @@
<?php
use Illuminate\Support\Facades\Route;
/**
* 'signed' middleware and 'signed/offline-payments' prefix applied to all routes (including names)
*
* @see \App\Providers\Route::register
*/
Route::signed('offline-payments', function () {
Route::get('invoices/{invoice}', 'Payment@show')->name('invoices.show');
Route::post('invoices/{invoice}/confirm', 'Payment@confirm')->name('invoices.confirm');
});

View File

@ -0,0 +1,33 @@
<?php
namespace Modules\OfflinePayments\Tests\Feature;
use Tests\Feature\PaymentTestCase;
class PaymentsTest extends PaymentTestCase
{
public $alias = 'offline-payments';
public $payment_request = [
'payment_method' => 'offline-payments.cash.99',
'type' => 'income',
];
public $setting_request = [
'name' => 'offline payments',
'code' => 'offline-payments.cash.99',
'customer' => 1,
'order' => 1,
'description' => 'description',
];
public function testItShouldPayFromSigned()
{
$this->assertPaymentFromSigned();
}
public function testItShouldPayFromPortal()
{
$this->assertPaymentFromPortal();
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace Modules\OfflinePayments\Tests\Feature;
use Modules\OfflinePayments\Jobs\CreatePaymentMethod;
use Tests\Feature\FeatureTestCase;
class SettingsTest extends FeatureTestCase
{
public function testItShouldSeeOfflinePaymentsSettingsListPage()
{
$this->loginAs()
->get(route('offline-payments.settings.edit'))
->assertOk()
->assertSeeText(trans('offline-payments::general.payment_gateways'))
->assertSeeText(trans('offline-payments::general.description'));
}
public function testItShouldSeeOfflinePaymentsSettingsCreatePage()
{
$this->loginAs()
->get(route('offline-payments.settings.edit'))
->assertOk()
->assertSeeText(trans('offline-payments::general.add_new'));
}
public function testItShouldCreateOfflinePaymentsSettings()
{
$request = $this->getRequest();
$message = trans('messages.success.added', ['type' => $request['name']]);
$this->loginAs()
->post(route('offline-payments.settings.update'), $request)
->assertOk()
->assertJson([
'success' => true,
'message' => $message,
]);
}
public function testItShouldSeeOfflinePaymentsSettingsUpdatePage()
{
$payment_method = $this->dispatch(new CreatePaymentMethod($this->getRequest()));
$this->loginAs()
->post(route('offline-payments.settings.get', ['code' => $payment_method['code']]))
->assertOk()
->assertJson([
'success' => true,
'data' => [
'name' => $payment_method['name'],
],
]);
}
public function testItShouldUpdateOfflinePaymentsSettings()
{
$request = $this->getRequest();
$payment_method = $this->dispatch(new CreatePaymentMethod($request));
$request['name'] = $this->faker->text(10);
$request['update_code'] = $payment_method['code'];
$message = trans('messages.success.updated', ['type' => $request['name']]);
$this->loginAs()
->post(route('offline-payments.settings.update'), $request)
->assertOk()
->assertJson([
'success' => true,
'message' => $message,
]);
}
public function testItShouldDeleteAccount()
{
$payment_method = $this->dispatch(new CreatePaymentMethod($this->getRequest()));
$message = trans('messages.success.deleted', ['type' => $payment_method['name']]);
$this->loginAs()
->delete(route('offline-payments.settings.delete', ['code' => $payment_method['code']]))
->assertOk()
->assertJson([
'success' => true,
'message' => $message,
]);
}
public function getRequest()
{
return [
'name' => $this->faker->text(10),
'code' => 'offline-payments.cash.99',
'customer' => 1,
'order' => 1,
'description' => $this->faker->paragraph,
];
}
}

View File

@ -0,0 +1,21 @@
{
"config": {
"autoloader-suffix": "offlinePayments"
},
"name": "akaunting/module-offline-payments",
"description": "Offline Payments app for Akaunting",
"homepage": "https://akaunting.com",
"keywords": [
"laravel",
"accounting",
"invoice",
"akaunting",
"offline-payments"
],
"license": "GPL-3.0+",
"scripts": {
"test": [
"composer install --prefer-dist --no-interaction --no-scripts --no-suggest --no-progress --no-ansi"
]
}
}

View File

@ -0,0 +1,14 @@
"project_id_env": CROWDIN_PROJECT_ID
"api_token_env": CROWDIN_PERSONAL_TOKEN
"preserve_hierarchy": true
"files": [
{
"source": "Resources/lang/en-GB/general.php",
"dest": "offline-payments/general.php",
"translation": "Resources/lang/%locale%/offline-payments/general.php",
"translation_replace": {
"offline-payments/": ""
}
}
]

View File

@ -0,0 +1,3 @@
{
"/Resources/assets/js/offline-payments.min.js": "/Resources/assets/js/offline-payments.min.js"
}

View File

@ -0,0 +1,20 @@
{
"alias": "offline-payments",
"icon": "credit_card",
"version": "3.0.4",
"active": 1,
"providers": [
"Modules\\OfflinePayments\\Providers\\Event",
"Modules\\OfflinePayments\\Providers\\Main"
],
"aliases": {},
"files": [],
"requires": [],
"reports": [],
"widgets": [],
"settings": [],
"extra-modules": {},
"routes": {
"redirect_after_install": "offline-payments.settings.edit"
}
}

15954
modules/OfflinePayments/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,21 @@
{
"name": "module-offline-payments",
"version": "3.0.0",
"private": true,
"scripts": {
"dev": "npm run development",
"development": "mix",
"watch": "mix watch",
"watch-poll": "mix watch -- --watch-options-poll=1000",
"hot": "mix watch --hot",
"prod": "npm run production",
"production": "mix --production"
},
"devDependencies": {
"cross-env": "^5.2.1",
"laravel-mix": "^6.0.39",
"resolve-url-loader": "^5.0.0"
},
"author": "Akaunting Inc <info@akaunting.com>",
"description": "Free Accounting Software"
}

22
modules/OfflinePayments/webpack.mix.js vendored Normal file
View File

@ -0,0 +1,22 @@
const mix = require('laravel-mix');
/*
|--------------------------------------------------------------------------
| Mix Asset Management
|--------------------------------------------------------------------------
|
| Mix provides a clean, fluent API for defining some Webpack build steps
| for your Laravel application. By default, we are compiling the Sass
| file for the application as well as bundling up all the JS files.
|
*/
//mix.js('resources/assets/js/views/**/*.js', 'public/js')
mix.options({
terser: {
extractComments: false,
}
})
.js('Resources/assets/js/offline-payments.js', 'Resources/assets/js/offline-payments.min.js')
.vue();

View File

@ -0,0 +1,292 @@
name: Releases
on:
release:
types: [released]
jobs:
check-json-version:
runs-on: ubuntu-20.04
steps:
- name: Checkout module
uses: actions/checkout@v3
- id: set_var
run: |
content=`cat ./module.json`
# the following lines are only required for multi line json
content="${content//'%'/'%25'}"
content="${content//$'\n'/'%0A'}"
content="${content//$'\r'/'%0D'}"
# end of optional handling for multi line json
echo "::set-output name=moduleJson::$content"
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Check version equality
run: |
if [ "${{fromJson(steps.set_var.outputs.moduleJson).version}}" == $RELEASE_VERSION ]; then
exit 0
else
exit 1
fi
tests:
needs: [check-json-version]
name: PHP ${{ matrix.php }}
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['8.1', '8.2']
steps:
- name: Checkout Akaunting
uses: actions/checkout@v3
with:
repository: akaunting/akaunting
- name: Checkout module to tmp path
uses: actions/checkout@v3
with:
path: tmp-path-for-module
- id: set_var
run: |
content=`cat ./tmp-path-for-module/module.json`
# the following lines are only required for multi line json
content="${content//'%'/'%25'}"
content="${content//$'\n'/'%0A'}"
content="${content//$'\r'/'%0D'}"
# end of optional handling for multi line json
echo "::set-output name=moduleJson::$content"
- name: Create module folder name which is PascalCase
run: |
MODULE_FOLDER_NAME=$(echo -n ${{fromJson(steps.set_var.outputs.moduleJson).alias}} | sed -r 's/(^|-)([a-z])/\U\2/g')
echo "MODULE_FOLDER_NAME=${MODULE_FOLDER_NAME}" >> $GITHUB_ENV
- name: Remove the tmp folder
run: rm -rf tmp-path-for-module
- name: Checkout module
uses: actions/checkout@v3
with:
path: modules/${{ env.MODULE_FOLDER_NAME }}
- name: Cache Composer
uses: actions/cache@v1
with:
path: ~/.composer/cache/files
key: php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }}
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: bcmath, ctype, dom, fileinfo, intl, gd, json, mbstring, pdo, pdo_sqlite, openssl, sqlite, xml, zip
coverage: none
- name: Copy .env
run: cp .env.testing .env
- name: Install Composer
run: cd "modules/${{ env.MODULE_FOLDER_NAME }}" ; composer test ; cd ../.. ; composer test
- name: Execute tests
run: php artisan test --parallel
build:
needs: [tests]
runs-on: ubuntu-20.04
steps:
- name: Checkout Akaunting
uses: actions/checkout@v3
with:
repository: akaunting/akaunting
- name: Checkout module to tmp path
uses: actions/checkout@v3
with:
path: tmp-path-for-module
- id: set_var
run: |
content=`cat ./tmp-path-for-module/module.json`
# the following lines are only required for multi line json
content="${content//'%'/'%25'}"
content="${content//$'\n'/'%0A'}"
content="${content//$'\r'/'%0D'}"
# end of optional handling for multi line json
echo "::set-output name=moduleJson::$content"
- name: Remove the tmp folder
run: rm -rf tmp-path-for-module
- name: Create module folder name which is PascalCase
run: |
MODULE_FOLDER_NAME=$(echo -n ${{fromJson(steps.set_var.outputs.moduleJson).alias}} | sed -r 's/(^|-)([a-z])/\U\2/g')
echo "MODULE_FOLDER_NAME=${MODULE_FOLDER_NAME}" >> $GITHUB_ENV
- name: Checkout module
uses: actions/checkout@v3
with:
path: modules/${{ env.MODULE_FOLDER_NAME }}
- name: Cache Composer
uses: actions/cache@v1
with:
path: ~/.composer/cache/files
key: php-8.1-composer-${{ hashFiles('composer.json') }}
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
extensions: bcmath, ctype, dom, fileinfo, intl, gd, json, mbstring, pdo, pdo_sqlite, openssl, sqlite, xml, zip
coverage: none
- name: Copy .env
run: cp .env.testing .env
- name: Install Composer
run: cd "modules/${{ env.MODULE_FOLDER_NAME }}" ; composer install --no-dev ; cd ../.. ; composer install --no-dev
- name: Run npm install for Akaunting
run: npm install
- name: Run npm install for the module
run: |
cd "modules/${{ env.MODULE_FOLDER_NAME }}"
npm install
- name: Compile module assets
run: |
cd "modules/${{ env.MODULE_FOLDER_NAME }}"
npm run production
- name: Install zip
run: sudo apt install zip
- name: Delete sass folder
run: |
cd "modules/${{ env.MODULE_FOLDER_NAME }}/Resources/assets"
rm -rf sass
- name: Delete mix-manifest.json file
run: |
cd "modules/${{ env.MODULE_FOLDER_NAME }}"
rm -f mix-manifest.json
- name: Delete non .min.js files
run: |
cd "modules/${{ env.MODULE_FOLDER_NAME }}/Resources/assets/js"
find . -type f ! -name '*.min.js' -delete
- name: ZIP
run: cd "modules/${{ env.MODULE_FOLDER_NAME }}" ; zip -r ../../final-build.zip . * -x '*.git*' -x '*node_modules*' -x 'composer.json' -x 'composer.lock' -x 'crowdin.yml' -x 'package-lock.json' -x 'package.json' -x 'README.md' -x 'webpack.mix.js' ; cd ..
- uses: actions/upload-artifact@master
with:
name: final-build
path: ${{ github.workspace }}/final-build.zip
upload-to-site:
needs: [build]
runs-on: ubuntu-20.04
steps:
- uses: actions/download-artifact@master
with:
name: final-build
- name: Install unzip
run: sudo apt install unzip
- name: UNZIP
run: unzip final-build.zip -d src
- id: set_var
run: |
content=`cat ./src/module.json`
# the following lines are only required for multi line json
content="${content//'%'/'%25'}"
content="${content//$'\n'/'%0A'}"
content="${content//$'\r'/'%0D'}"
# end of optional handling for multi line json
echo "::set-output name=moduleJson::$content"
- name: Rename zip archive
run: mv final-build.zip ${{fromJson(steps.set_var.outputs.moduleJson).alias}}-v${{fromJson(steps.set_var.outputs.moduleJson).version}}.zip
- name: Upload to release
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{fromJson(steps.set_var.outputs.moduleJson).alias}}-v${{fromJson(steps.set_var.outputs.moduleJson).version}}.zip
tag: ${{ github.ref }}
overwrite: true
- name: Upload to Akaunting Site
run: |
curl \
-H "Authorization: Bearer ${{ secrets.AKAUNTING_SITE_AUTH_BEARER }}" \
-H "Module-Version: ${{fromJson(steps.set_var.outputs.moduleJson).version}}" \
-F "file=@./${{fromJson(steps.set_var.outputs.moduleJson).alias}}-v${{fromJson(steps.set_var.outputs.moduleJson).version}}.zip" \
--retry 5 \
--retry-delay 60 \
${{ secrets.AKAUNTING_SITE_API_DOMAIN }}/${{ secrets.AKAUNTING_SITE_API_MODULES_ENDPOINT }}/${{fromJson(steps.set_var.outputs.moduleJson).alias}}
update-cloud:
needs: [upload-to-site]
runs-on: ubuntu-20.04
steps:
- uses: actions/download-artifact@master
with:
name: final-build
- name: Install unzip
run: sudo apt install unzip
- name: UNZIP
run: unzip final-build.zip -d src
- id: set_var
run: |
content=`cat ./src/module.json`
# the following lines are only required for multi line json
content="${content//'%'/'%25'}"
content="${content//$'\n'/'%0A'}"
content="${content//$'\r'/'%0D'}"
# end of optional handling for multi line json
echo "::set-output name=moduleJson::$content"
- name: Update the Cloud
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.AKAUNTING_CLOUD_HOST }}
username: ${{ secrets.AKAUNTING_CLOUD_USERNAME }}
key: ${{ secrets.AKAUNTING_CLOUD_SSH_PRIVATE }}
script: |
cd /${{ secrets.AKAUNTING_CLOUD_PATH }}
${{ secrets.AKAUNTING_CLOUD_UPDATE_COMMAND }} ${{fromJson(steps.set_var.outputs.moduleJson).alias}} 1 ${{fromJson(steps.set_var.outputs.moduleJson).version}}

View File

@ -0,0 +1,55 @@
name: Tests
on:
schedule:
- cron: '0 0 * * 0'
workflow_dispatch:
jobs:
tests:
name: PHP ${{ matrix.php }}
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.1', '8.2']
steps:
- name: Checkout Akaunting
uses: actions/checkout@v3
with:
repository: akaunting/akaunting
- name: Checkout Paypal Standard module
uses: actions/checkout@v3
with:
path: modules/PaypalStandard
- name: Cache Composer
uses: actions/cache@v1
with:
path: ~/.composer/cache/files
key: php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }}
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: bcmath, ctype, dom, fileinfo, intl, gd, json, mbstring, pdo, pdo_sqlite, openssl, sqlite, xml, zip
coverage: none
- name: Copy .env
run: cp .env.testing .env
- name: Install Composer
run: cd modules/PaypalStandard ; composer test ; cd ../.. ; composer test
- name: Execute tests
run: php artisan test --parallel

View File

@ -0,0 +1,37 @@
name: Translations
on:
schedule:
- cron: '0 0 * * 0'
workflow_dispatch:
jobs:
sync:
name: Sync
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Sync with Crowdin
uses: crowdin/github-action@master
with:
upload_sources: true
upload_translations: true
download_translations: true
skip_untranslated_files: true
localization_branch_name: 'translations'
commit_message: 'new crowdin translations'
pull_request_title: 'New Crowdin translations'
pull_request_body: 'https://crowdin.com/project/akaunting-apps'
pull_request_labels: 'Translation'
config: 'crowdin.yml'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_APPS_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

7
modules/PaypalStandard/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
/.idea
/.history
/.vscode
/.vagrant
/node_modules
/storage/*.key
/vendor

View File

@ -0,0 +1,172 @@
<?php
namespace Modules\PaypalStandard\Http\Controllers;
use App\Abstracts\Http\PaymentController;
use App\Http\Requests\Portal\InvoicePayment as PaymentRequest;
use App\Models\Document\Document;
use GuzzleHttp\Client;
use Illuminate\Http\Request;
class Payment extends PaymentController
{
public $alias = 'paypal-standard';
public $type = 'redirect';
public function show(Document $invoice, PaymentRequest $request, $cards = [])
{
$setting = $this->setting;
$this->setContactFirstLastName($invoice);
$setting['action'] = 'https://www.paypal.com/cgi-bin/webscr';
if (isset($setting['mode']) && $setting['mode'] == 'sandbox') {
$setting['action'] = 'https://www.sandbox.paypal.com/cgi-bin/webscr';
}
$invoice_url = $this->getInvoiceUrl($invoice);
$return_url = $this->getReturnUrl($invoice);
$confirm_url = $this->getConfirmUrl($invoice);
return response()->json([
'code' => $setting['code'],
'name' => $setting['name'],
'description' => trans('paypal-standard::general.description'),
'redirect' => false,
'html' => view('paypal-standard::show', compact('setting', 'invoice', 'invoice_url', 'return_url', 'confirm_url'))->render(),
]);
}
/**
* Handle the PayPal return redirect.
*
* Security (CWE-345): The return route is a user-facing redirect URL. It
* must NOT mutate financial state (create transactions, mark invoices as
* paid) because it trusts client-supplied POST data. Laravel's signed URL
* validation only covers the URL path and query string, not the request
* body — an attacker can POST `payment_status=Completed` to the signed
* return URL without making a real PayPal payment.
*
* This method now only displays a "Thank You / Processing" screen by
* redirecting to the finish page. The actual payment is processed
* exclusively via the server-to-server `confirm` (IPN) callback, where
* the transaction is cryptographically verified with PayPal.
*
* @param Document $invoice
* @param Request $request
* @return \Illuminate\Http\RedirectResponse
*/
public function return(Document $invoice, Request $request)
{
$message = trans('paypal-standard::general.payment.processing');
flash($message)->success();
return redirect($this->getFinishUrl($invoice));
}
/**
* Handle the PayPal IPN (Instant Payment Notification) callback.
*
* This is a server-to-server callback from PayPal. The notification is
* verified by re-posting the data to PayPal and checking for a `VERIFIED`
* response. Only after verification (and matching receiver email + amount)
* is the `PaymentReceived` event dispatched, which creates the transaction
* and marks the invoice as paid.
*
* @param Document $invoice
* @param Request $request
* @return void
*/
public function confirm(Document $invoice, Request $request)
{
$setting = $this->setting;
$paypal_log = $this->logger;
if (!$invoice) {
return;
}
$url = ($setting['mode'] == 'live') ? 'https://ipnpb.paypal.com/cgi-bin/webscr' : 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr';
$client = new Client(['verify' => false]);
$paypal_request['cmd'] = '_notify-validate';
foreach ($request->toArray() as $key => $value) {
$paypal_request[$key] = urlencode(html_entity_decode($value, ENT_QUOTES, 'UTF-8'));
}
try {
$response = $client->post($url, $paypal_request);
} catch (\Exception $e) {
$paypal_log->info('PAYPAL_STANDARD :: CURL failed ' . $e->getMessage());
return;
}
if ($response->getStatusCode() != 200) {
$paypal_log->info('PAYPAL_STANDARD :: CURL failed ' . $response->getBody()->getContents());
return;
}
$response_body = $response->getBody()->getContents();
if (!empty($setting['debug'])) {
$paypal_log->info('PAYPAL_STANDARD :: IPN REQUEST: ', $request->toArray());
$paypal_log->info('PAYPAL_STANDARD :: IPN RESPONSE: ' . $response_body);
}
// Security: only accept PayPal's cryptographically verified response.
// The previous condition used `||` which was always true (any string is
// either not 'VERIFIED' OR not 'UNVERIFIED'), causing the method to
// return early and never process any payment — defeating IPN entirely.
if (strcmp($response_body, 'VERIFIED') !== 0) {
$paypal_log->info('PAYPAL_STANDARD :: IPN NOT VERIFIED: ' . $response_body);
return;
}
switch ($request['payment_status']) {
case 'Completed':
$receiver_match = (strtolower($request['receiver_email']) == strtolower($setting['email']));
$total_paid_match = ((double) $request['mc_gross'] == $invoice->amount);
if ($receiver_match && $total_paid_match) {
// Use PayPal's transaction ID as the reference.
$this->setReference($invoice, $request['txn_id']);
$this->dispatchPaidEvent($invoice, $request->merge(['type' => 'income']));
$this->forgetReference($invoice);
$paypal_log->info('PAYPAL_STANDARD :: Payment Received for Invoice: ' . $invoice->id . ' - Txn ID: ' . $request['txn_id']);
}
if (!$receiver_match) {
$paypal_log->info('PAYPAL_STANDARD :: RECEIVER EMAIL MISMATCH! ' . strtolower($request['receiver_email']));
}
if (!$total_paid_match) {
$paypal_log->info('PAYPAL_STANDARD :: TOTAL PAID MISMATCH! ' . $request['mc_gross']);
}
break;
case 'Canceled_Reversal':
case 'Denied':
case 'Expired':
case 'Failed':
case 'Pending':
case 'Processed':
case 'Refunded':
case 'Reversed':
case 'Voided':
$paypal_log->info('PAYPAL_STANDARD :: NOT COMPLETED: ' . $request['payment_status']);
break;
}
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Modules\PaypalStandard\Listeners;
use App\Events\Auth\LandingPageShowing as Event;
class AddLandingPage
{
/**
* Handle the event.
*
* @param Event $event
* @return void
*/
public function handle(Event $event)
{
$event->user->landing_pages['paypal-standard.settings.edit'] = trans('paypal-standard::general.name');
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace Modules\PaypalStandard\Listeners;
use App\Events\Module\PaymentMethodShowing as Event;
class ShowAsPaymentMethod
{
/**
* Handle the event.
*
* @param Event $event
* @return void
*/
public function handle(Event $event)
{
$method = setting('paypal-standard');
$method['code'] = 'paypal-standard';
$event->modules->payment_methods[] = $method;
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Modules\PaypalStandard\Providers;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as Provider;
class Event extends Provider
{
/**
* Determine if events and listeners should be automatically discovered.
*
* @return bool
*/
public function shouldDiscoverEvents()
{
return true;
}
/**
* Get the listener directories that should be used to discover events.
*
* @return array
*/
protected function discoverEventsWithin()
{
return [
__DIR__ . '/../Listeners',
];
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace Modules\PaypalStandard\Providers;
use Illuminate\Support\ServiceProvider as Provider;
class Main extends Provider
{
/**
* Boot the application events.
*
* @return void
*/
public function boot()
{
$this->loadTranslations();
$this->loadViews();
}
/**
* Register the service provider.
*
* @return void
*/
public function register()
{
$this->loadRoutes();
}
/**
* Load views.
*
* @return void
*/
public function loadViews()
{
$this->loadViewsFrom(__DIR__ . '/../Resources/views', 'paypal-standard');
}
/**
* Load translations.
*
* @return void
*/
public function loadTranslations()
{
$this->loadTranslationsFrom(__DIR__ . '/../Resources/lang', 'paypal-standard');
}
/**
* Load routes.
*
* @return void
*/
public function loadRoutes()
{
if (app()->routesAreCached()) {
return;
}
$routes = [
'guest.php',
'portal.php',
'signed.php',
];
foreach ($routes as $route) {
$this->loadRoutesFrom(__DIR__ . '/../Routes/' . $route);
}
}
/**
* Get the services provided by the provider.
*
* @return array
*/
public function provides()
{
return [];
}
}

View File

@ -0,0 +1,11 @@
# PayPal Standard app for Akaunting
[![Tests](https://github.com/akaunting/module-paypal-standard/workflows/Tests/badge.svg?label=tests)](https://github.com/akaunting/module-paypal-standard/actions)
## Tests
The workflow runs both [Akaunting](https://github.com/akaunting/akaunting/tree/master/tests) and module test suites. They're configured to run once per week and triggered manually. Therefore, **before** publishing a new release, run the workflow [manually](https://github.com/akaunting/module-paypal-standard/actions?query=workflow%3ATests) and make sure it passes.
## Translations
[Crowdin](https://crowdin.com/project/akaunting-apps) is the home of translators and it's synced (download & upload) with GitHub. The workflow is configured to run once per week and triggered manually. Therefore, **before** publishing a new release, run the workflow [manually](https://github.com/akaunting/module-paypal-standard/actions?query=workflow%3ATranslations) and merge the automatically created PR, if available. Finally, all language files must be listed in the [config](https://github.com/akaunting/module-paypal-standard/blob/master/crowdin.yml) file.

View File

@ -0,0 +1,20 @@
<?php
return [
'name' => 'معيار الباي بال',
'description' => 'تمكين خيار الدفع القياسي لـ باي بال',
'form' => [
'email' => 'البريد الإلكتروني',
'mode' => 'نمط',
'debug' => 'تصحيح الأخطاء',
'transaction' => 'المعاملات',
'customer' => 'إظهار للعميل',
'order' => 'طلب',
],
'test_mode' => 'تحذير: بوابة الدفع في وضع "Sandbox". لن يتم احتساب مبالغ على حسابك.',
//'description' => 'Pay with PAYPAL',
];

View File

@ -0,0 +1,20 @@
<?php
return [
'name' => 'PayPal estàndard',
'description' => 'Activa el pagament estàndard amb PayPal',
'form' => [
'email' => 'Correu electrònic',
'mode' => 'Mode',
'debug' => 'Depura',
'transaction' => 'Transacció',
'customer' => 'Mostra al client',
'order' => 'Comanda',
],
'test_mode' => 'Avís: La passarel·la de pagament està en \'mode de proves\'. El teu compte no es veurà afectat per l\'acció.',
//'description' => 'Pay with PAYPAL',
];

View File

@ -0,0 +1,20 @@
<?php
return [
'name' => 'PayPal Standard',
'description' => 'Aktivér standard betalingsmulighed for PayPal',
'form' => [
'email' => 'E-mail',
'mode' => 'Mode',
'debug' => 'Fejlfinde',
'transaction' => 'Transaktion',
'customer' => 'Vis til kunde',
'order' => 'Rækkefølge',
],
'test_mode' => 'Advarsel: Betalingsgatewayen er i \'Sandbox Mode\'. Din konto bliver ikke debiteret.',
//'description' => 'Pay with PAYPAL',
];

View File

@ -0,0 +1,20 @@
<?php
return [
'name' => 'PayPal Standard',
'description' => 'Aktivieren Sie die Standard-Zahlungsoption von PayPal',
'form' => [
'email' => 'E-Mail',
'mode' => 'Modus',
'debug' => 'Debug',
'transaction' => 'Transaktion',
'customer' => 'Dem Kunden zeigen',
'order' => 'Bestellung',
],
'test_mode' => 'Warnung: Das Payment-Gateway befindet sich im <b>Sandbox-Modus</b>. Ihr Konto wird nicht belastet.',
//'description' => 'Pay with PAYPAL',
];

View File

@ -0,0 +1,26 @@
<?php
return [
'name' => 'PayPal Standard',
'description' => 'Enable the standard payment option of PayPal',
'form' => [
'email' => 'Email',
'mode' => 'Mode',
'debug' => 'Debug',
'transaction' => 'Transaction',
'customer' => 'Show to Customer',
'order' => 'Order',
],
'payment' => [
'pending' => 'Payment is pending',
'not_added' => 'Payment not added!',
'processing' => 'Thank you! Your payment is being processed. You will be notified once it is confirmed.',
],
'test_mode' => 'Warning: The payment gateway is in \'Sandbox Mode\'. Your account will not be charged.',
//'description' => 'Pay with PAYPAL',
];

View File

@ -0,0 +1,20 @@
<?php
return [
'name' => 'PayPal استاندارد',
'description' => 'گزینه پرداخت استاندارد PayPal را فعال کنید',
'form' => [
'email' => 'ایمیل',
'mode' => 'حالت',
'debug' => 'اشکال زدايي کردن',
'transaction' => 'تراکنش‌ها',
'customer' => 'نمایش به مشتری',
'order' => 'سفارش',
],
'test_mode' => 'هشدار: درگاه پرداخت در "حالت Sandbox" است. حساب شما شارژ نخواهد شد.',
//'description' => 'Pay with PAYPAL',
];

Some files were not shown because too many files have changed in this diff Show More