Commit 2c95f617 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(financial): InstaPay transfers, reviewed before they become money

S5. The academy publishes a handle, the member transfers and records what
they sent, and staff turn that claim into a payment only after matching it
against the academy's own statement.

An unverified screenshot must never create a Payment. `transactions` are
immutable and recordPayment() forces status = Confirmed and posts to the
ledger immediately, so a proof is a separate object with its own lifecycle
and only approval calls recordPayment() — double-entry happens exactly once
and nothing in the ledger is ever edited.

**A screenshot is not evidence.** It is a convenience. The control is
`sender_reference`, unique per academy per method behind a partial index,
which kills replay, cross-invoice reuse and "someone else's transfer against
my invoice" in one constraint. The reviewer types the amount from the
statement; `amount_claimed` is what the payer said and is never what gets
posted.

Concurrency is a conditional UPDATE, not a disabled button. Two reviewers
open the queue and both see an enabled Approve; the second one's UPDATE
matches zero rows and raises InvalidStatusTransitionException. A row that has
left `pending` is frozen by a BEFORE UPDATE trigger — approving a proof is
the moral equivalent of taking cash, and Auditable::createAuditLog() takes
its user from auth() at boot and silently writes nothing when it cannot
resolve an academy, so the approval facts are columns on the row rather than
an audit-log dependency.

Overpayment is capped at what is due and the excess is deposited to the
member's wallet in the same transaction. InvoiceStatus::Overpaid exists but
nothing consumes it and it drives due_amount negative, after which
getCollectionRate() and ParticipantBillingService start summing negatives.

branch_id is NOT NULL on a proof. Revenue is branch-attributed only through
payments, so a NULL-branch payment lands in the all-branches total and in no
branch — the columns stop summing with no error anywhere.

E6 decided as recommended: all five method CHECKs that lacked `instapay` get
it, the till included. Reception will take an InstaPay transfer within a
month of launch, and the failure mode of leaving the POS out is a Postgres
23514 at the till in front of a customer. pos_transactions and
pos_split_payments also gain `bank_transfer`, which they never had.

The review queue ships before the member-facing upload, on purpose: a proof
that can be submitted and never reviewed is a promise to a member that nobody
is keeping.

Proof files go to the private disk and are streamed by a controller that
authorises the submitter, a co-guardian of the same member, and staff holding
payments.approve_proof — Content-Disposition: attachment, nosniff, no-store.
The parent excuse form wrote its medical attachments to the public disk; that
is the mistake not to repeat.

Verified against a restored copy of backups/oc_sport-20260831-081053.dump: a
600 EGP transfer against a 500 EGP invoice posts 500 to the invoice
(Dr 1010 Bank / Cr 4000 Training Revenue, branch attributed) and 100 to the
wallet; duplicate reference, self-approval, zero amount, second approval and
editing a settled row are all refused.

Suite: 87 passed, 3 skipped locally; 11 InstaPay tests pass against the
restored tenant.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent acca60b0
......@@ -7,6 +7,7 @@
case Cash = 'cash';
case Card = 'card';
case BankTransfer = 'bank_transfer';
case InstaPay = 'instapay';
case Wallet = 'wallet';
case Online = 'online';
case Cheque = 'cheque';
......@@ -18,6 +19,7 @@ public function label(): string
self::Cash => 'نقدي',
self::Card => 'بطاقة',
self::BankTransfer => 'تحويل بنكي',
self::InstaPay => 'إنستاباي',
self::Wallet => 'محفظة',
self::Online => 'إلكتروني',
self::Cheque => 'شيك',
......
<?php
namespace App\Domain\Financial\Models;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\BelongsToBranch;
use App\Domain\Shared\Traits\HasUuid;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PaymentProof extends Model
{
use HasUuid, BelongsToAcademy, BelongsToBranch, Auditable;
public const STATUSES = ['pending', 'under_review', 'approved', 'rejected', 'superseded'];
protected $fillable = [
'academy_id', 'branch_id', 'invoice_id', 'participant_id', 'submitted_by',
'amount_claimed', 'amount_approved', 'method',
'sender_reference', 'sender_phone', 'transferred_at',
'proof_path', 'proof_mime', 'proof_size',
'status', 'reviewed_by', 'reviewed_at',
'second_reviewed_by', 'second_reviewed_at',
'rejection_reason', 'review_notes', 'review_ip',
'payment_id', 'metadata',
];
protected function casts(): array
{
return [
'amount_claimed' => 'integer',
'amount_approved' => 'integer',
'proof_size' => 'integer',
'transferred_at' => 'datetime',
'reviewed_at' => 'datetime',
'second_reviewed_at' => 'datetime',
'metadata' => 'array',
];
}
public function invoice(): BelongsTo
{
return $this->belongsTo(Invoice::class);
}
public function participant(): BelongsTo
{
return $this->belongsTo(Participant::class);
}
public function payment(): BelongsTo
{
return $this->belongsTo(Payment::class);
}
public function submitter(): BelongsTo
{
return $this->belongsTo(User::class, 'submitted_by');
}
public function reviewer(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewed_by');
}
public function isOpen(): bool
{
return in_array($this->status, ['pending', 'under_review'], true);
}
public function statusLabel(): string
{
return match ($this->status) {
'pending' => 'في انتظار المراجعة',
'under_review' => 'قيد المراجعة',
'approved' => 'تم الاعتماد',
'rejected' => 'مرفوض',
'superseded' => 'مُستبدل',
default => $this->status,
};
}
/** Approving a proof is the moral equivalent of taking cash. */
public function isFinancialAudit(): bool
{
return true;
}
}
This diff is collapsed.
......@@ -6,6 +6,8 @@
{
case Cash = 'cash';
case Card = 'card';
case BankTransfer = 'bank_transfer';
case InstaPay = 'instapay';
case Wallet = 'wallet';
case Split = 'split';
}
<?php
namespace App\Http\Controllers\Financial;
use App\Domain\Financial\Models\PaymentProof;
use App\Domain\Identity\Services\GuardianResolver;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Streams a transfer proof from the private disk, to the two parties entitled
* to see it: the member who submitted it, and staff who may review it.
*
* It is a stream rather than a URL because the file is on the private disk and
* must stay there. The deleted mobile API's DocumentController returned
* `asset('storage/'.$file_path)` for documents on the private disk — a URL
* that either 404s or, if the file were ever moved to make it work, serves
* national IDs and medical records to anyone with the path.
*/
class PaymentProofFileController extends Controller
{
public function __invoke(Request $request, string $uuid): StreamedResponse
{
$proof = PaymentProof::where('uuid', $uuid)->firstOrFail();
abort_unless($this->mayView($request, $proof), 403);
$disk = Storage::disk('local');
abort_unless($disk->exists($proof->proof_path), 404);
return $disk->response(
$proof->proof_path,
'proof-' . $proof->uuid . '.' . pathinfo($proof->proof_path, PATHINFO_EXTENSION),
[
// Never inline. An attachment cannot execute in the page's
// origin, whatever the browser decides the file really is.
'Content-Disposition' => 'attachment',
'X-Content-Type-Options' => 'nosniff',
'Cache-Control' => 'private, no-store',
]
);
}
private function mayView(Request $request, PaymentProof $proof): bool
{
$user = $request->user();
if (! $user) {
return false;
}
if ((int) $proof->submitted_by === (int) $user->id) {
return true;
}
if ($user->can('payments.approve_proof')) {
return true;
}
// A second guardian of the same member may see it too — they share the
// invoice it belongs to.
return $proof->participant_id
&& in_array((int) $proof->participant_id, app(GuardianResolver::class)->participantIdsFor($user), true);
}
}
<?php
namespace App\Livewire\Financial;
use App\Domain\Financial\Models\PaymentProof;
use App\Domain\Financial\Services\PaymentProofService;
use App\Domain\Shared\Context\BranchContext;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Exceptions\InvalidStatusTransitionException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
/**
* The staff side of InstaPay, built before the member side on purpose: a proof
* that can be submitted and not reviewed is a promise to a member that nobody
* is keeping.
*
* The reviewer types the amount from the academy's statement rather than
* accepting what the payer claimed. That single field is the control — the
* screenshot is a convenience, and treating it as evidence is what makes this
* kind of flow forgeable.
*/
#[Layout('layouts.app')]
#[Title('إثباتات التحويل')]
class PaymentProofQueue extends Component
{
use WithPagination;
#[Url(as: 'status')]
public string $status = 'pending';
#[Locked]
public ?int $reviewingId = null;
public string $verifiedAmount = '';
public string $reviewNotes = '';
public string $rejectionReason = '';
public function mount(): void
{
$this->authorize('payments.approve_proof');
}
public function updatedStatus(): void
{
if (! in_array($this->status, PaymentProof::STATUSES, true)) {
$this->status = 'pending';
}
$this->resetPage();
}
public function startReview(int $proofId): void
{
$this->authorize('payments.approve_proof');
$proof = $this->findInScope($proofId);
$this->reviewingId = $proof->id;
// Pre-filled from the claim only as a starting point; the reviewer is
// expected to correct it against the statement.
$this->verifiedAmount = number_format($proof->amount_claimed / 100, 2, '.', '');
$this->reviewNotes = '';
$this->rejectionReason = '';
}
public function cancelReview(): void
{
$this->reviewingId = null;
}
public function approve(PaymentProofService $proofs): void
{
$this->authorize('payments.approve_proof');
$this->validate([
'verifiedAmount' => ['required', 'numeric', 'min:0.01'],
'reviewNotes' => ['nullable', 'string', 'max:1000'],
], [
'verifiedAmount.required' => __('أدخل المبلغ كما يظهر في كشف الحساب'),
'verifiedAmount.min' => __('المبلغ يجب أن يكون أكبر من صفر'),
]);
$proof = $this->findInScope($this->reviewingId);
try {
$payment = $proofs->approve(
$proof,
(int) round(((float) $this->verifiedAmount) * 100),
auth()->user(),
$this->reviewNotes ?: null,
request()->ip(),
);
session()->flash('success', __('تم اعتماد التحويل وتسجيل الدفعة') . ' ' . $payment->reference);
} catch (InvalidStatusTransitionException|DomainException $e) {
$this->addError('verifiedAmount', $e->getMessage());
return;
}
$this->reviewingId = null;
}
public function reject(PaymentProofService $proofs): void
{
$this->authorize('payments.approve_proof');
$this->validate([
'rejectionReason' => ['required', 'string', 'max:40'],
'reviewNotes' => ['nullable', 'string', 'max:1000'],
], [
'rejectionReason.required' => __('اذكر سبب الرفض — سيراه العضو'),
]);
$proof = $this->findInScope($this->reviewingId);
try {
$proofs->reject($proof, $this->rejectionReason, auth()->user(), $this->reviewNotes ?: null, request()->ip());
session()->flash('success', __('تم رفض إثبات التحويل'));
} catch (InvalidStatusTransitionException $e) {
$this->addError('rejectionReason', $e->getMessage());
return;
}
$this->reviewingId = null;
}
public function render()
{
$branchId = app(BranchContext::class)->branchId();
$query = PaymentProof::where('status', $this->status)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->with(['invoice', 'participant.person', 'submitter', 'reviewer'])
->orderBy('created_at');
return view('livewire.financial.payment-proof-queue', [
'proofs' => $query->paginate(15),
'pendingCount' => PaymentProof::whereIn('status', ['pending', 'under_review'])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->count(),
'reviewing' => $this->reviewingId ? PaymentProof::with(['invoice.items', 'participant.person'])->find($this->reviewingId) : null,
'threshold' => PaymentProofService::MAKER_CHECKER_THRESHOLD,
]);
}
/**
* The tenant scope is a global scope, and the branch filter is applied
* again here rather than trusted from the list query — the id arrives from
* the browser.
*/
private function findInScope(?int $proofId): PaymentProof
{
abort_if($proofId === null, 400);
$branchId = app(BranchContext::class)->branchId();
return PaymentProof::where('id', $proofId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->firstOrFail();
}
}
<?php
namespace App\Livewire\Portal;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Services\PaymentProofService;
use App\Domain\Identity\Services\BranchSettingsService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Livewire\Portal\Concerns\PortalScreen;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithFileUploads;
/**
* "I transferred the money" — the member's half of the InstaPay flow.
*
* Nothing here creates a payment. It records a claim, and staff turn that into
* money only after checking the academy's own statement.
*/
#[Layout('layouts.portal')]
#[Title('تسجيل تحويل')]
class PortalPayProof extends Component
{
use PortalScreen, WithFileUploads;
#[Locked]
public int $invoiceId;
public string $amount = '';
public string $senderReference = '';
public string $senderPhone = '';
public string $transferredAt = '';
public $proof;
public function mount(string $invoice): void
{
$this->authorizePortal('portal.pay');
$found = Invoice::where('uuid', $invoice)->firstOrFail();
$this->assertPayable($found);
$this->invoiceId = $found->id;
$this->amount = number_format($found->due_amount / 100, 2, '.', '');
$this->senderPhone = auth()->user()?->phone ?? '';
$this->transferredAt = now()->format('Y-m-d');
}
protected function rules(): array
{
return [
'amount' => ['required', 'numeric', 'min:0.01', 'max:9999999'],
'senderReference' => ['required', 'string', 'min:4', 'max:64'],
'senderPhone' => ['nullable', 'string', 'max:20'],
'transferredAt' => ['required', 'date', 'before_or_equal:today'],
// Images and PDFs only, and small enough that a phone can send it
// on a bad connection.
'proof' => ['required', 'file', 'max:4096', 'mimes:png,jpg,jpeg,webp,pdf'],
];
}
protected function messages(): array
{
return [
'amount.required' => __('المبلغ مطلوب'),
'amount.numeric' => __('المبلغ يجب أن يكون رقماً'),
'amount.min' => __('المبلغ يجب أن يكون أكبر من صفر'),
'senderReference.required' => __('رقم عملية التحويل مطلوب'),
'senderReference.min' => __('رقم العملية قصير جداً'),
'transferredAt.required' => __('تاريخ التحويل مطلوب'),
'transferredAt.before_or_equal' => __('لا يمكن أن يكون تاريخ التحويل في المستقبل'),
'proof.required' => __('صورة إثبات التحويل مطلوبة'),
'proof.mimes' => __('الملف يجب أن يكون صورة أو PDF'),
'proof.max' => __('حجم الملف يتجاوز ٤ ميجابايت'),
];
}
public function submit(PaymentProofService $proofs): void
{
$this->validate();
$invoice = Invoice::findOrFail($this->invoiceId);
// Re-checked here and not only in mount(): the invoice may have been
// settled or cancelled between rendering the form and submitting it.
$this->assertPayable($invoice);
// The file lands on the private disk. A transfer screenshot carries a
// phone number, a name and a bank balance — the parent excuse form
// wrote its attachments to the public disk and that is exactly the
// mistake not to repeat.
$path = $this->proof->store("proofs/{$invoice->academy_id}", 'local');
try {
$proofs->submit([
'invoice_id' => $invoice->id,
'branch_id' => $invoice->branch_id ?: $this->resolveBranchId($invoice),
'amount_claimed' => (int) round(((float) $this->amount) * 100),
'method' => 'instapay',
'sender_reference' => $this->senderReference,
'sender_phone' => $this->senderPhone ?: null,
'transferred_at' => $this->transferredAt,
'proof_path' => $path,
'proof_mime' => $this->proof->getMimeType(),
'proof_size' => $this->proof->getSize(),
], auth()->user());
} catch (DomainException $e) {
// Never leave an orphan file behind when the record was refused.
\Illuminate\Support\Facades\Storage::disk('local')->delete($path);
$this->addError('senderReference', $e->getMessage());
return;
}
session()->flash('success', __('تم استلام إثبات التحويل — ستراجعه الإدارة قريباً'));
$this->redirect(route('portal.invoice', $invoice->uuid), navigate: true);
}
public function render()
{
$invoice = Invoice::findOrFail($this->invoiceId);
$branchId = $invoice->branch_id ?: $this->resolveBranchId($invoice);
$settings = app(BranchSettingsService::class);
return view('livewire.portal.portal-pay-proof', [
'invoice' => $invoice,
// Per-branch handle and instructions live in branch_settings, the
// generic per-branch key-value store that already exists.
'handle' => $branchId ? $settings->get($branchId, 'instapay.handle') : null,
'instructions' => $branchId ? $settings->get($branchId, 'instapay.instructions') : null,
]);
}
private function assertPayable(Invoice $invoice): void
{
abort_unless(
$invoice->billable_type === Participant::class
&& $this->portal()->mayPayFor((int) $invoice->billable_id),
403,
__('لا تملك صلاحية الدفع عن هذا العضو')
);
if ($invoice->due_amount <= 0) {
abort(403, __('لا يوجد مستحق على هذه الفاتورة'));
}
}
/**
* Branch is required at the service boundary. A payment with no branch
* lands in the all-branches total and in no branch, and the columns stop
* summing with no error anywhere.
*/
private function resolveBranchId(Invoice $invoice): ?int
{
if ($invoice->billable_type !== Participant::class) {
return null;
}
$participant = Participant::find($invoice->billable_id);
return $participant?->branch_id
?? auth()->user()?->preferred_branch_id
?? auth()->user()?->branch_id;
}
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Six CHECK constraints carry a payment-method vocabulary and they already
* disagree with each other: `payslips` allows 'instapay' and nothing else
* does, and `pos_transactions` / `pos_split_payments` have no 'bank_transfer'
* at all.
*
* All five that lack it get 'instapay' in one migration, the till included.
* Reception will take an InstaPay transfer within a month of launch, and the
* failure mode of leaving the POS out is a Postgres 23514 at the till, in
* front of a customer, with no way for the cashier to complete the sale.
*
* Widening a CHECK is safe against a populated table: every row that passed
* the old list passes the superset. DROP IF EXISTS + ADD run inside the
* migration's transaction, so a failure leaves the original in place.
*/
return new class extends Migration
{
private const OFFICE = ['cash', 'card', 'bank_transfer', 'instapay', 'wallet', 'online', 'cheque', 'other'];
/** The till also gains bank_transfer, which it has always been missing. */
private const TILL = ['cash', 'card', 'bank_transfer', 'instapay', 'wallet', 'split'];
private const TARGETS = [
['payments', 'method', 'payments_method_check', self::OFFICE],
['expenses', 'payment_method', 'expenses_payment_method_check', self::OFFICE],
['facility_rent_payments', 'payment_method', 'facility_rent_payments_payment_method_check', self::OFFICE],
['pos_transactions', 'payment_method', 'pos_transactions_payment_method_check', self::TILL],
['pos_split_payments', 'payment_method', 'pos_split_payments_payment_method_check', self::TILL],
];
public function up(): void
{
if (DB::getDriverName() !== 'pgsql') {
return;
}
foreach (self::TARGETS as [$table, $column, $constraint, $values]) {
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
continue;
}
$list = implode(', ', array_map(fn ($v) => "'{$v}'", $values));
DB::statement("ALTER TABLE {$table} DROP CONSTRAINT IF EXISTS {$constraint}");
DB::statement("ALTER TABLE {$table} ADD CONSTRAINT {$constraint} CHECK ({$column} IN ({$list}))");
}
}
public function down(): void
{
// Deliberately empty. Narrowing a CHECK would fail on any row already
// recorded as instapay, and the failure would land mid-boot.
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* A member's claim that they sent money, pending a human check.
*
* The financial invariants forbid the obvious shortcut. `transactions` are
* immutable, PaymentService::recordPayment() forces status = Confirmed and
* posts to the ledger immediately — so **an unverified screenshot must never
* create a Payment**. It creates one of these instead, and only approval calls
* recordPayment(), so double-entry happens exactly once and nothing in the
* ledger is ever edited.
*
* A screenshot is not evidence. It is a convenience; the control is
* reconciliation against the academy's own InstaPay or bank record.
* `sender_reference` is the backbone: one per academy per method, enforced by
* a partial unique index, which kills replay, cross-invoice reuse and
* "someone else's transfer against my invoice" in a single constraint.
*
* Two unique indexes, from two different concerns, both kept: one live proof
* per invoice (so a member cannot flood the queue), and one payment per proof
* (so an approval cannot be posted twice).
*
* `timestamps()` on purpose: a proof is a request with a lifecycle, not a
* ledger record. The ledger row it eventually produces is the immutable one.
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('payment_proofs')) {
return;
}
Schema::create('payment_proofs', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('academy_id')->constrained()->cascadeOnDelete();
// NOT NULL: revenue is branch-attributed only through payments, and
// a NULL-branch payment lands in the all-branches total and in no
// branch at all — the columns stop summing with no error anywhere.
$table->foreignId('branch_id')->constrained('branches');
$table->foreignId('invoice_id')->constrained();
$table->foreignId('participant_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('submitted_by')->constrained('users');
$table->bigInteger('amount_claimed'); // what the payer typed
$table->bigInteger('amount_approved')->nullable(); // what the reviewer read off the statement
$table->string('method', 20)->default('instapay');
$table->string('sender_reference', 64)->nullable();
$table->string('sender_phone', 20)->nullable();
$table->timestamp('transferred_at')->nullable();
// Private disk. A transfer screenshot carries a phone number, a
// name and a bank balance.
$table->string('proof_path');
$table->string('proof_mime', 60)->nullable();
$table->unsignedBigInteger('proof_size')->nullable();
$table->string('status', 20)->default('pending');
$table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('reviewed_at')->nullable();
$table->foreignId('second_reviewed_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('second_reviewed_at')->nullable();
$table->string('rejection_reason', 40)->nullable();
$table->text('review_notes')->nullable();
$table->string('review_ip', 45)->nullable();
$table->foreignId('payment_id')->nullable()->constrained()->nullOnDelete();
$table->jsonb('metadata')->default('{}');
$table->timestamps();
$table->index(['academy_id', 'status', 'created_at']);
$table->index(['academy_id', 'branch_id', 'status']);
});
if (DB::getDriverName() !== 'pgsql') {
return;
}
DB::statement("ALTER TABLE payment_proofs ADD CONSTRAINT payment_proofs_status_check CHECK (status IN ('pending','under_review','approved','rejected','superseded'))");
DB::statement("ALTER TABLE payment_proofs ADD CONSTRAINT payment_proofs_method_check CHECK (method IN ('instapay','bank_transfer'))");
DB::statement('ALTER TABLE payment_proofs ADD CONSTRAINT payment_proofs_amount_check CHECK (amount_claimed > 0 AND (amount_approved IS NULL OR amount_approved > 0))');
// An approved proof without a payment is a proof that took money and
// told no one. The constraint makes that state unrepresentable.
DB::statement("ALTER TABLE payment_proofs ADD CONSTRAINT payment_proofs_approved_payment_check CHECK (status <> 'approved' OR payment_id IS NOT NULL)");
DB::statement('CREATE UNIQUE INDEX payment_proofs_one_payment ON payment_proofs (payment_id) WHERE payment_id IS NOT NULL');
DB::statement("CREATE UNIQUE INDEX payment_proofs_one_pending ON payment_proofs (academy_id, invoice_id) WHERE status IN ('pending','under_review')");
DB::statement("CREATE UNIQUE INDEX payment_proofs_sender_ref ON payment_proofs (academy_id, method, sender_reference) WHERE sender_reference IS NOT NULL AND status <> 'rejected'");
// Append-only after submission: once a proof leaves `pending` the payer
// may not change what they claimed, and nobody may rewrite the review
// trail. Enforced in the database because approving a proof is the
// moral equivalent of taking cash, and Auditable::createAuditLog()
// silently writes nothing when it cannot resolve an academy.
DB::unprepared(<<<'SQL'
CREATE OR REPLACE FUNCTION payment_proofs_freeze() RETURNS trigger AS $$
BEGIN
IF OLD.status NOT IN ('pending', 'under_review') THEN
IF NEW.amount_claimed IS DISTINCT FROM OLD.amount_claimed
OR NEW.amount_approved IS DISTINCT FROM OLD.amount_approved
OR NEW.proof_path IS DISTINCT FROM OLD.proof_path
OR NEW.invoice_id IS DISTINCT FROM OLD.invoice_id
OR NEW.payment_id IS DISTINCT FROM OLD.payment_id
OR NEW.status IS DISTINCT FROM OLD.status THEN
RAISE EXCEPTION 'payment_proofs row % is settled and cannot be altered', OLD.id;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER payment_proofs_freeze_trigger
BEFORE UPDATE ON payment_proofs
FOR EACH ROW EXECUTE FUNCTION payment_proofs_freeze();
SQL);
}
public function down(): void
{
if (DB::getDriverName() === 'pgsql') {
DB::unprepared('DROP TRIGGER IF EXISTS payment_proofs_freeze_trigger ON payment_proofs; DROP FUNCTION IF EXISTS payment_proofs_freeze();');
}
Schema::dropIfExists('payment_proofs');
}
};
<div class="space-y-5">
<header class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-xl font-bold text-gray-900">{{ __('إثباتات التحويل') }}</h1>
<p class="mt-0.5 text-sm text-gray-500">
{{ __('التحويلات التي سجّلها الأعضاء وتنتظر المطابقة مع كشف الحساب') }}
</p>
</div>
@if($pendingCount > 0)
<span class="rounded-full bg-amber-50 px-3 py-1.5 text-sm font-bold text-amber-700 border border-amber-200">
{{ $pendingCount }} {{ __('في الانتظار') }}
</span>
@endif
</header>
<div class="flex flex-wrap gap-1 border-b border-gray-200">
@foreach(['pending' => 'في الانتظار', 'under_review' => 'قيد المراجعة', 'approved' => 'معتمدة', 'rejected' => 'مرفوضة'] as $key => $label)
<button type="button" wire:click="$set('status', '{{ $key }}')"
@class([
'px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
'border-blue-600 text-blue-700' => $status === $key,
'border-transparent text-gray-500 hover:text-gray-700' => $status !== $key,
])>
{{ __($label) }}
</button>
@endforeach
</div>
@if($reviewing)
<section class="rounded-xl border-2 border-blue-200 bg-blue-50/40 p-4 sm:p-5">
<h2 class="font-bold text-gray-900">{{ __('مراجعة إثبات') }}</h2>
<dl class="mt-3 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<div>
<dt class="text-xs text-gray-500">{{ __('العضو') }}</dt>
<dd class="font-semibold">{{ $reviewing->participant?->person?->name_ar ?? '—' }}</dd>
</div>
<div>
<dt class="text-xs text-gray-500">{{ __('الفاتورة') }}</dt>
<dd class="font-semibold" dir="ltr">{{ $reviewing->invoice?->number }}</dd>
</div>
<div>
<dt class="text-xs text-gray-500">{{ __('المستحق') }}</dt>
<dd class="font-semibold" dir="ltr">{{ format_money((int) $reviewing->invoice?->due_amount) }}</dd>
</div>
<div>
<dt class="text-xs text-gray-500">{{ __('المبلغ المُدّعى') }}</dt>
<dd class="font-semibold" dir="ltr">{{ format_money((int) $reviewing->amount_claimed) }}</dd>
</div>
<div>
<dt class="text-xs text-gray-500">{{ __('رقم العملية') }}</dt>
<dd class="font-mono text-xs" dir="ltr">{{ $reviewing->sender_reference }}</dd>
</div>
<div>
<dt class="text-xs text-gray-500">{{ __('رقم المُحوِّل') }}</dt>
<dd class="font-mono text-xs" dir="ltr">{{ $reviewing->sender_phone ?: '—' }}</dd>
</div>
<div>
<dt class="text-xs text-gray-500">{{ __('تاريخ التحويل') }}</dt>
<dd class="text-xs" dir="ltr">{{ $reviewing->transferred_at?->format('Y-m-d') ?: '—' }}</dd>
</div>
<div>
<dt class="text-xs text-gray-500">{{ __('الإثبات') }}</dt>
<dd>
<a href="{{ route('proofs.file', $reviewing->uuid) }}"
class="text-xs font-semibold text-blue-700 hover:underline">{{ __('تنزيل الملف') }}</a>
</dd>
</div>
</dl>
@if($reviewing->amount_claimed >= $threshold)
<p class="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
{{ __('مبلغ كبير — يُفضَّل توقيع ثانٍ من مسؤول آخر بعد الاعتماد.') }}
</p>
@endif
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="verified" class="block text-sm font-semibold text-gray-900">
{{ __('المبلغ كما يظهر في كشف الحساب') }}
</label>
{{-- The control, not the screenshot. This is the number that
reaches the ledger. --}}
<input id="verified" type="text" inputmode="decimal" dir="ltr" wire:model="verifiedAmount"
class="mt-1 w-full rounded-lg border-gray-300 font-mono">
@error('verifiedAmount') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
<p class="mt-1 text-xs text-gray-500">
{{ __('الزائد عن المستحق يُضاف إلى محفظة العضو تلقائياً') }}
</p>
</div>
<div>
<label for="notes" class="block text-sm font-semibold text-gray-900">{{ __('ملاحظات المراجعة') }}</label>
<textarea id="notes" rows="2" wire:model="reviewNotes"
class="mt-1 w-full rounded-lg border-gray-300 text-sm"></textarea>
</div>
</div>
<div class="mt-4 flex flex-wrap items-end gap-3">
<button type="button" wire:click="approve" wire:loading.attr="disabled" wire:target="approve"
class="rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-60">
<span wire:loading.remove wire:target="approve">{{ __('اعتماد وتسجيل الدفعة') }}</span>
<span wire:loading wire:target="approve">{{ __('جارٍ الاعتماد...') }}</span>
</button>
<div class="flex items-end gap-2">
<div>
<label for="reason" class="block text-xs font-semibold text-gray-700">{{ __('سبب الرفض') }}</label>
<input id="reason" type="text" maxlength="40" wire:model="rejectionReason"
class="mt-1 rounded-lg border-gray-300 text-sm">
</div>
<button type="button" wire:click="reject" wire:loading.attr="disabled" wire:target="reject"
class="rounded-xl border border-red-300 bg-white px-4 py-2.5 text-sm font-bold text-red-700 hover:bg-red-50 disabled:opacity-60">
{{ __('رفض') }}
</button>
</div>
<button type="button" wire:click="cancelReview"
class="rounded-xl px-4 py-2.5 text-sm font-medium text-gray-500 hover:text-gray-700">
{{ __('إلغاء') }}
</button>
</div>
@error('rejectionReason') <p class="mt-2 text-xs text-red-600">{{ $message }}</p> @enderror
</section>
@endif
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-xs text-gray-500">
<tr>
<th class="px-4 py-3 text-start font-medium">{{ __('العضو') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('الفاتورة') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('المبلغ') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('رقم العملية') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('أُرسل') }}</th>
<th class="px-4 py-3 text-end font-medium">{{ __('إجراء') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($proofs as $proof)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">{{ $proof->participant?->person?->name_ar ?? '—' }}</td>
<td class="px-4 py-3 font-mono text-xs" dir="ltr">{{ $proof->invoice?->number }}</td>
<td class="px-4 py-3 font-mono" dir="ltr">
{{ format_money((int) ($proof->amount_approved ?? $proof->amount_claimed)) }}
</td>
<td class="px-4 py-3 font-mono text-xs" dir="ltr">{{ $proof->sender_reference }}</td>
<td class="px-4 py-3 text-xs text-gray-500">{{ $proof->created_at?->diffForHumans() }}</td>
<td class="px-4 py-3 text-end">
@if($proof->isOpen())
<button type="button" wire:click="startReview({{ $proof->id }})"
class="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-bold text-white hover:bg-blue-700">
{{ __('مراجعة') }}
</button>
@else
<span class="text-xs text-gray-500">{{ $proof->statusLabel() }}</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-10 text-center text-gray-500">
{{ __('لا توجد إثباتات في هذه الحالة') }}
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div>{{ $proofs->links() }}</div>
</div>
<div class="space-y-4">
<div class="portal-card px-4 py-4">
<p class="num text-sm font-bold">{{ $invoice->number }}</p>
<p class="mt-2 text-xs" style="color: var(--portal-muted);">{{ __('المطلوب سداده') }}</p>
<p class="num text-3xl font-extrabold leading-none" style="color: var(--brand-danger);">
{{ format_money((int) $invoice->due_amount) }}
</p>
</div>
@if($handle)
<x-portal.card :title="__('حوّل إلى')">
<div class="flex items-center gap-2"
x-data="{ copied: false }">
<code class="num flex-1 truncate rounded-xl px-3 py-2.5 text-sm font-bold"
style="background: var(--brand-50); color: var(--brand-700);"
x-ref="handle">{{ $handle }}</code>
<button type="button" class="tap-target rounded-xl px-3 text-xs font-bold"
style="background: var(--brand-500); color: var(--brand-fg);"
@click="navigator.clipboard.writeText($refs.handle.textContent.trim()); copied = true; setTimeout(() => copied = false, 1800)">
<span x-show="!copied">{{ __('نسخ') }}</span>
<span x-show="copied" x-cloak>{{ __('تم') }}</span>
</button>
</div>
@if($instructions)
<p class="mt-3 whitespace-pre-line text-xs leading-relaxed" style="color: var(--portal-muted);">{{ $instructions }}</p>
@endif
</x-portal.card>
@else
<x-portal.card>
<p class="text-xs leading-relaxed" style="color: var(--brand-warning);">
{{ __('لم تُضِف الأكاديمية بيانات إنستاباي لهذا الفرع بعد. تواصل مع الإدارة قبل التحويل.') }}
</p>
</x-portal.card>
@endif
<form wire:submit="submit" class="portal-card space-y-4 px-4 py-4">
<h2 class="text-sm font-bold">{{ __('بيانات التحويل') }}</h2>
<div>
<label for="amount" class="block text-xs font-semibold">{{ __('المبلغ المحوَّل') }}</label>
{{-- dir="ltr" on every numeric input: an Arabic document renders a
number right-to-left otherwise and the caret jumps. --}}
<input id="amount" type="text" inputmode="decimal" dir="ltr" wire:model="amount"
class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);">
@error('amount') <p class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
</div>
<div>
<label for="reference" class="block text-xs font-semibold">{{ __('رقم عملية التحويل') }}</label>
<input id="reference" type="text" dir="ltr" wire:model="senderReference"
class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);">
<p class="mt-1 text-[11px]" style="color: var(--portal-muted);">
{{ __('الرقم الذي يظهر في رسالة التأكيد — به تُطابق الإدارة التحويل مع كشف الحساب') }}
</p>
@error('senderReference') <p class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="phone" class="block text-xs font-semibold">{{ __('رقم المُحوِّل') }}</label>
<input id="phone" type="tel" dir="ltr" wire:model="senderPhone"
class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);">
</div>
<div>
<label for="date" class="block text-xs font-semibold">{{ __('تاريخ التحويل') }}</label>
<input id="date" type="date" dir="ltr" wire:model="transferredAt"
class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);">
@error('transferredAt') <p class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
</div>
</div>
<div>
<label for="proof" class="block text-xs font-semibold">{{ __('صورة إثبات التحويل') }}</label>
<input id="proof" type="file" wire:model="proof" accept="image/*,application/pdf"
class="mt-1 block w-full text-xs">
<div wire:loading wire:target="proof" class="mt-1 text-[11px]" style="color: var(--portal-muted);">
{{ __('جارٍ رفع الملف...') }}
</div>
@error('proof') <p class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
</div>
<button type="submit" wire:loading.attr="disabled" wire:target="submit,proof"
class="w-full rounded-xl px-4 py-3 text-sm font-bold disabled:opacity-60"
style="background: var(--brand-500); color: var(--brand-fg);">
<span wire:loading.remove wire:target="submit">{{ __('إرسال إثبات التحويل') }}</span>
<span wire:loading wire:target="submit">{{ __('جارٍ الإرسال...') }}</span>
</button>
<p class="text-[11px] leading-relaxed" style="color: var(--portal-muted);">
{{ __('يُسجَّل المبلغ على الفاتورة بعد مراجعة الإدارة ومطابقته مع كشف الحساب، وليس فور الإرسال.') }}
</p>
</form>
</div>
......@@ -606,6 +606,13 @@
Route::get('/contacts', \App\Livewire\Website\ContactSubmissionList::class)->name('contacts');
});
// ─── InstaPay transfer proofs ───────────────────────────────
// The review queue ships before the member-facing upload: a proof that can
// be submitted and never reviewed is a promise nobody is keeping.
Route::get('/payment-proofs', \App\Livewire\Financial\PaymentProofQueue::class)
->middleware('permission:payments.approve_proof')
->name('payment-proofs.index');
// ─── Parents Portal ─────────────────────────────────────────
Route::prefix('parent')->name('parent.')->group(function () {
Route::get('/', \App\Livewire\Parent\ParentHome::class)->name('home');
......@@ -659,8 +666,17 @@
Route::get('/account', \App\Livewire\Portal\PortalAccount::class)->name('account');
Route::get('/notifications', \App\Livewire\Portal\PortalNotifications::class)->name('notifications');
Route::get('/pass', \App\Livewire\Portal\PortalPass::class)->name('pass');
Route::get('/payments/invoice/{invoice}/transfer', \App\Livewire\Portal\PortalPayProof::class)
->middleware('permission:portal.pay')
->name('invoice.transfer');
});
// A transfer proof is on the private disk and is streamed, never linked: the
// file carries a phone number, a name and a bank balance.
Route::middleware('auth')->get('/proofs/{uuid}', \App\Http\Controllers\Financial\PaymentProofFileController::class)
->name('proofs.file');
/*
|--------------------------------------------------------------------------
| Website builder pages (must stay last)
......
<?php
namespace Tests\Feature;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\PaymentProof;
use App\Domain\Financial\Models\Transaction;
use App\Domain\Financial\Models\Wallet;
use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Financial\Services\PaymentProofService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Exceptions\InvalidStatusTransitionException;
use App\Domain\Shared\Models\Academy;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* The InstaPay path, against a restored copy of a real tenant database.
*
* Postgres-only, and deliberately so: half the guarantees here ARE database
* objects — partial unique indexes on the sender reference and the pending
* proof, a CHECK making an approved proof without a payment unrepresentable,
* and a BEFORE UPDATE trigger freezing a settled row. Testing this on SQLite
* would assert the service layer while silently skipping every control that
* actually holds the line.
*
* DB_CONNECTION=pgsql DB_DATABASE=oc_sport_test ./vendor/bin/phpunit --filter PaymentProofTest
*/
class PaymentProofTest extends TestCase
{
private Academy $academy;
private User $member;
private User $staff;
private Participant $participant;
private int $branchId;
protected function setUp(): void
{
parent::setUp();
if (config('database.default') !== 'pgsql') {
$this->markTestSkipped('Needs a restored Postgres tenant; see the class comment.');
}
// Deliberately NOT Event::fake(): HasUuid generates the uuid from a
// model `creating` event, and faking events leaves every row with a
// null uuid against a NOT NULL column.
$this->academy = Academy::firstOrFail();
$this->app->instance('current_academy', $this->academy);
$this->participant = Participant::whereNotNull('branch_id')->firstOrFail();
$this->branchId = (int) $this->participant->branch_id;
$users = User::withoutGlobalScopes()->where('academy_id', $this->academy->id)->limit(2)->get();
$this->member = $users[0];
$this->staff = $users[1];
DB::beginTransaction();
}
protected function tearDown(): void
{
if (config('database.default') === 'pgsql') {
DB::rollBack();
}
parent::tearDown();
}
private function anOpenInvoice(int $total = 50000): Invoice
{
$invoice = app(InvoiceService::class)->create([
'academy_id' => $this->academy->id,
'branch_id' => $this->branchId,
'type' => 'standard',
'billable_type' => Participant::class,
'billable_id' => $this->participant->id,
'total_amount' => $total,
], [['description' => 'اشتراك', 'quantity' => 1, 'unit_price' => $total]], $this->staff);
$invoice->update(['status' => 'sent']);
return $invoice->refresh();
}
private function aProof(Invoice $invoice, int $claimed, string $reference): PaymentProof
{
return app(PaymentProofService::class)->submit([
'invoice_id' => $invoice->id,
'branch_id' => $this->branchId,
'amount_claimed' => $claimed,
'method' => 'instapay',
'sender_reference' => $reference,
'proof_path' => 'proofs/test.jpg',
], $this->member);
}
// ---- what a proof is, and is not ---------------------------------------
public function test_submitting_a_proof_moves_no_money(): void
{
$invoice = $this->anOpenInvoice();
$this->aProof($invoice, 50000, 'REF-A1');
$this->assertSame(50000, (int) $invoice->refresh()->due_amount, 'a screenshot is not a payment');
$this->assertSame(0, (int) $invoice->paid_amount);
}
public function test_a_sender_reference_cannot_be_used_twice(): void
{
// The backbone control: one reference per academy per method kills
// replay, cross-invoice reuse, and claiming someone else's transfer.
$this->aProof($this->anOpenInvoice(), 50000, 'REF-B1');
$this->expectException(DomainException::class);
$this->aProof($this->anOpenInvoice(), 50000, 'ref-b1 ');
}
public function test_only_one_proof_may_be_pending_on_an_invoice(): void
{
$invoice = $this->anOpenInvoice();
$this->aProof($invoice, 50000, 'REF-C1');
$this->expectException(DomainException::class);
$this->aProof($invoice, 50000, 'REF-C2');
}
public function test_a_cancelled_invoice_refuses_a_proof(): void
{
$invoice = $this->anOpenInvoice();
$invoice->update(['status' => 'cancelled']);
$this->expectException(DomainException::class);
$this->aProof($invoice, 50000, 'REF-D1');
}
// ---- approval ----------------------------------------------------------
public function test_approval_posts_the_verified_amount_not_the_claimed_one(): void
{
$invoice = $this->anOpenInvoice(50000);
$proof = $this->aProof($invoice, 90000, 'REF-E1'); // payer overstates
$payment = app(PaymentProofService::class)->approve($proof, 40000, $this->staff);
$this->assertSame(40000, (int) $payment->amount, 'the reviewer reads the statement, not the screenshot');
$this->assertSame(10000, (int) $invoice->refresh()->due_amount);
}
public function test_an_overpayment_is_capped_and_the_excess_goes_to_the_wallet(): void
{
// Never InvoiceStatus::Overpaid: nothing consumes it and due_amount
// goes negative, after which getCollectionRate() and
// ParticipantBillingService start summing negative numbers.
$invoice = $this->anOpenInvoice(50000);
$proof = $this->aProof($invoice, 60000, 'REF-F1');
$before = (int) (Wallet::where('owner_type', Participant::class)
->where('owner_id', $this->participant->id)->value('balance') ?? 0);
$payment = app(PaymentProofService::class)->approve($proof, 60000, $this->staff);
$invoice->refresh();
$this->assertSame(50000, (int) $payment->amount);
$this->assertSame(0, (int) $invoice->due_amount);
$this->assertSame('paid', $invoice->status->value);
$after = (int) Wallet::where('owner_type', Participant::class)
->where('owner_id', $this->participant->id)->value('balance');
$this->assertSame($before + 10000, $after, 'the excess is credited, not discarded');
}
public function test_approval_posts_a_real_ledger_entry_with_a_branch(): void
{
$invoice = $this->anOpenInvoice();
$proof = $this->aProof($invoice, 50000, 'REF-G1');
$payment = app(PaymentProofService::class)->approve($proof, 50000, $this->staff);
$rows = Transaction::where('payment_id', $payment->id)->get();
$this->assertNotEmpty($rows, 'an approved transfer must reach the ledger');
$this->assertSame(50000, (int) $rows->sum('amount'));
$this->assertSame($this->branchId, (int) $rows->first()->branch_id);
}
public function test_a_reviewer_cannot_approve_their_own_submission(): void
{
$proof = $this->aProof($this->anOpenInvoice(), 50000, 'REF-H1');
$this->expectException(DomainException::class);
app(PaymentProofService::class)->approve($proof, 50000, $this->member);
}
public function test_a_second_approval_finds_nothing_left_to_approve(): void
{
// The concurrency control is the conditional UPDATE, not a disabled
// button: two reviewers both see an enabled Approve.
$invoice = $this->anOpenInvoice();
$proof = $this->aProof($invoice, 50000, 'REF-I1');
app(PaymentProofService::class)->approve($proof, 50000, $this->staff);
$this->expectException(InvalidStatusTransitionException::class);
app(PaymentProofService::class)->approve($proof->refresh(), 50000, $this->staff);
}
public function test_a_settled_proof_cannot_be_edited(): void
{
$invoice = $this->anOpenInvoice();
$proof = $this->aProof($invoice, 50000, 'REF-J1');
app(PaymentProofService::class)->approve($proof, 50000, $this->staff);
// Enforced by a database trigger, because approving a proof is the
// moral equivalent of taking cash and Auditable::createAuditLog()
// silently writes nothing when it cannot resolve an academy.
$this->expectException(\Illuminate\Database\QueryException::class);
DB::table('payment_proofs')->where('id', $proof->id)->update(['amount_approved' => 999999]);
}
public function test_rejecting_leaves_the_invoice_untouched(): void
{
$invoice = $this->anOpenInvoice();
$proof = $this->aProof($invoice, 50000, 'REF-K1');
app(PaymentProofService::class)->reject($proof, 'لا يوجد تحويل مطابق', $this->staff);
$this->assertSame('rejected', $proof->refresh()->status);
$this->assertSame(50000, (int) $invoice->refresh()->due_amount);
// A rejected reference is released, so a member who mistyped it once
// is not locked out of ever submitting that transfer.
$this->aProof($this->anOpenInvoice(), 50000, 'REF-K1');
$this->assertTrue(true);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment