update project
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Finance\DTO\InvoiceItemData;
|
||||
|
||||
interface ChargeServiceContract
|
||||
{
|
||||
public function applyItem(SchoolContext $context, int $invoiceId, InvoiceItemData $item): array;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Finance\Enums\FinanceAuditAction;
|
||||
|
||||
interface FinanceAuditLoggerContract
|
||||
{
|
||||
public function log(SchoolContext $context, FinanceAuditAction $action, string $entityType, int|string $entityId, ?array $before = null, ?array $after = null, ?string $reason = null, ?string $idempotencyKey = null, array $metadata = []): void;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Finance\DTO\PaymentFileData;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
interface FinanceFileServiceContract
|
||||
{
|
||||
public function attachPaymentFile(SchoolContext $context, int $paymentId, PaymentFileData $file): array;
|
||||
public function downloadPaymentFile(SchoolContext $context, int $paymentId, string $mode): BinaryFileResponse;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Finance\DTO\CreateInvoiceData;
|
||||
|
||||
interface InvoiceServiceContract
|
||||
{
|
||||
public function createInvoice(SchoolContext $context, CreateInvoiceData $data): array;
|
||||
public function recalculateInvoice(SchoolContext $context, int $invoiceId): array;
|
||||
public function voidInvoice(SchoolContext $context, int $invoiceId, string $reason): array;
|
||||
public function getInvoice(SchoolContext $context, int $invoiceId): array;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Finance\Support\Money;
|
||||
|
||||
interface PaymentAllocationPolicyContract
|
||||
{
|
||||
public function allocate(SchoolContext $context, array $invoiceView, Money $paymentAmount): array;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Finance\DTO\EditPaymentData;
|
||||
use App\Domain\SchoolCore\Finance\DTO\RecordPaymentData;
|
||||
|
||||
interface PaymentServiceContract
|
||||
{
|
||||
public function recordPayment(SchoolContext $context, RecordPaymentData $data): array;
|
||||
public function editPayment(SchoolContext $context, int $paymentId, EditPaymentData $data): array;
|
||||
public function reversePayment(SchoolContext $context, int $paymentId, string $reason): array;
|
||||
public function getPayment(SchoolContext $context, int $paymentId): array;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Finance\DTO\RefundPaymentData;
|
||||
|
||||
interface RefundServiceContract
|
||||
{
|
||||
public function refundPayment(SchoolContext $context, RefundPaymentData $data): array;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
|
||||
interface TuitionPolicyContract
|
||||
{
|
||||
public function calculateExpectedCharges(SchoolContext $context, array $studentFinanceProfile): array;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\DTO;
|
||||
|
||||
final readonly class CreateInvoiceData
|
||||
{
|
||||
/** @param list<InvoiceItemData> $items */
|
||||
public function __construct(public ?int $studentId, public ?int $guardianId, public array $items, public string $currency, public ?string $dueDate = null, public ?string $notes = null, public array $metadata = [])
|
||||
{
|
||||
if ($items === []) { throw new \InvalidArgumentException('Invoice requires at least one item.'); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\DTO;
|
||||
|
||||
use App\Domain\SchoolCore\Finance\Enums\PaymentMethod;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
final readonly class EditPaymentData
|
||||
{
|
||||
public function __construct(public ?MoneyData $amount, public ?PaymentMethod $paymentMethod, public ?DateTimeImmutable $paymentDate, public ?string $referenceNumber, public ?string $notes, public ?UploadedFile $replacementFile, public string $editReason, public array $metadata = [])
|
||||
{
|
||||
if (trim($editReason) === '') { throw new \InvalidArgumentException('Edit reason is required.'); }
|
||||
if ($amount !== null && $amount->amountMinor <= 0) { throw new \InvalidArgumentException('Payment amount must be positive.'); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\DTO;
|
||||
|
||||
final readonly class FinanceActorData
|
||||
{
|
||||
/** @param list<int> $roleIds */
|
||||
public function __construct(public int $actorUserId, public array $roleIds, public array $permissions = []) {}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\DTO;
|
||||
|
||||
final readonly class InvoiceItemData
|
||||
{
|
||||
public function __construct(public string $description, public MoneyData $amount, public string $type = 'charge', public array $metadata = [])
|
||||
{
|
||||
if ($description === '') { throw new \InvalidArgumentException('Invoice item description is required.'); }
|
||||
if (! in_array($type, ['charge','discount','scholarship','adjustment'], true)) { throw new \InvalidArgumentException('Unsupported item type.'); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\DTO;
|
||||
|
||||
final readonly class MoneyData
|
||||
{
|
||||
public function __construct(public int $amountMinor, public string $currency, public int $scale = 2)
|
||||
{
|
||||
if ($this->scale < 0 || $this->scale > 6) { throw new \InvalidArgumentException('Money scale must be between 0 and 6.'); }
|
||||
if (! preg_match('/^[A-Z]{3}$/', $this->currency)) { throw new \InvalidArgumentException('Currency must be an ISO currency code.'); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\DTO;
|
||||
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
final readonly class PaymentFileData
|
||||
{
|
||||
public function __construct(public UploadedFile $file, public string $fileType = 'receipt', public array $metadata = [])
|
||||
{
|
||||
if (! in_array($fileType, ['receipt','check','proof','other'], true)) { throw new \InvalidArgumentException('Unsupported payment file type.'); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\DTO;
|
||||
|
||||
use App\Domain\SchoolCore\Finance\Enums\PaymentMethod;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
final readonly class RecordPaymentData
|
||||
{
|
||||
public function __construct(public int $invoiceId, public ?int $studentId, public ?int $guardianId, public MoneyData $amount, public PaymentMethod $paymentMethod, public DateTimeImmutable $paymentDate, public ?string $referenceNumber = null, public ?string $notes = null, public ?string $idempotencyKey = null, public ?UploadedFile $uploadedFile = null, public array $metadata = [])
|
||||
{
|
||||
if ($invoiceId <= 0) { throw new \InvalidArgumentException('Invoice ID is required.'); }
|
||||
if ($amount->amountMinor <= 0) { throw new \InvalidArgumentException('Payment amount must be positive.'); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\DTO;
|
||||
|
||||
final readonly class RefundPaymentData
|
||||
{
|
||||
public function __construct(public int $paymentId, public MoneyData $amount, public string $reason, public ?string $idempotencyKey = null, public array $metadata = [])
|
||||
{
|
||||
if ($paymentId <= 0) { throw new \InvalidArgumentException('Payment ID is required.'); }
|
||||
if ($amount->amountMinor <= 0) { throw new \InvalidArgumentException('Refund amount must be positive.'); }
|
||||
if (trim($reason) === '') { throw new \InvalidArgumentException('Refund reason is required.'); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Data;
|
||||
|
||||
final readonly class CreateInvoiceData
|
||||
{
|
||||
/** @param array<int, array<string, mixed>> $items */
|
||||
public function __construct(public int $studentId, public array $items, public ?string $dueDate = null) {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Data;
|
||||
|
||||
final readonly class EditPaymentData
|
||||
{
|
||||
/** @param array<string, mixed> $changes */
|
||||
public function __construct(public array $changes, public string $reason) {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Data;
|
||||
|
||||
final readonly class InvoiceReadModel
|
||||
{
|
||||
/** @param array<string, mixed> $attributes */
|
||||
public function __construct(public int $id, public int $totalCents, public array $attributes = []) {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Data;
|
||||
|
||||
final readonly class PaymentReadModel
|
||||
{
|
||||
/** @param array<string, mixed> $attributes */
|
||||
public function __construct(public int $id, public int $amountCents, public array $attributes = []) {}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Data;
|
||||
|
||||
final readonly class RecordPaymentData
|
||||
{
|
||||
public function __construct(public int $invoiceId, public int $amountCents, public string $method, public ?string $idempotencyKey = null) {}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Enums;
|
||||
|
||||
enum FinanceAuditAction: string
|
||||
{
|
||||
case InvoiceCreated = 'invoice.created';
|
||||
case InvoiceUpdated = 'invoice.updated';
|
||||
case InvoiceVoided = 'invoice.voided';
|
||||
case InvoiceRecalculated = 'invoice.recalculated';
|
||||
case PaymentRecorded = 'payment.recorded';
|
||||
case PaymentEdited = 'payment.edited';
|
||||
case PaymentReversed = 'payment.reversed';
|
||||
case PaymentFileAttached = 'payment.file_attached';
|
||||
case PaymentFileDownloaded = 'payment.file_downloaded';
|
||||
case RefundCreated = 'refund.created';
|
||||
case ChargeApplied = 'charge.applied';
|
||||
case DiscountApplied = 'discount.applied';
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Enums;
|
||||
|
||||
enum InvoiceStatus: string
|
||||
{
|
||||
case Draft = 'draft';
|
||||
case Open = 'open';
|
||||
case Partial = 'partial';
|
||||
case Paid = 'paid';
|
||||
case Overpaid = 'overpaid';
|
||||
case Void = 'void';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Enums;
|
||||
|
||||
enum LedgerDirection: string
|
||||
{
|
||||
case Debit = 'debit';
|
||||
case Credit = 'credit';
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Enums;
|
||||
|
||||
enum PaymentMethod: string
|
||||
{
|
||||
case Cash = 'cash';
|
||||
case Check = 'check';
|
||||
case Card = 'card';
|
||||
case BankTransfer = 'bank_transfer';
|
||||
case Other = 'other';
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Enums;
|
||||
|
||||
enum PaymentStatus: string
|
||||
{
|
||||
case Recorded = 'recorded';
|
||||
case Edited = 'edited';
|
||||
case Reversed = 'reversed';
|
||||
case Refunded = 'refunded';
|
||||
case PartiallyRefunded = 'partially_refunded';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Exceptions;
|
||||
|
||||
class FinanceConcurrencyException extends FinanceException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Exceptions;
|
||||
|
||||
class FinanceException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Exceptions;
|
||||
|
||||
class InvalidPaymentAmountException extends FinanceException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Exceptions;
|
||||
|
||||
class InvoiceNotFoundException extends FinanceException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Exceptions;
|
||||
|
||||
class InvoiceOverpaymentException extends FinanceException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Exceptions;
|
||||
|
||||
class PaymentNotFoundException extends FinanceException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Exceptions;
|
||||
|
||||
class UnauthorizedFinanceAccessException extends FinanceException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Policies;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Finance\Contracts\PaymentAllocationPolicyContract;
|
||||
use App\Domain\SchoolCore\Finance\Support\Money;
|
||||
|
||||
final class DefaultPaymentAllocationPolicy implements PaymentAllocationPolicyContract
|
||||
{
|
||||
public function allocate(SchoolContext $context, array $invoiceView, Money $paymentAmount): array
|
||||
{
|
||||
return [['invoice_id'=>$invoiceView['id'] ?? null,'amount_minor'=>$paymentAmount->minorAmount(),'currency'=>$paymentAmount->currency(),'strategy'=>'oldest_open_invoice_first']];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Policies;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Finance\Contracts\TuitionPolicyContract;
|
||||
|
||||
final class DefaultTuitionPolicy implements TuitionPolicyContract
|
||||
{
|
||||
public function calculateExpectedCharges(SchoolContext $context, array $studentFinanceProfile): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# SchoolCore Finance
|
||||
|
||||
Owns neutral finance correctness: invoices, payments, refunds, allocations, audit, idempotency, files, transactions, and balances. Forbidden here: Islamic Sunday School vocabulary, raw Request objects, auth/request helpers, filename-only file access, and float money math.
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Repositories;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class FinanceReadRepository
|
||||
{
|
||||
public function outstandingInvoices(SchoolContext $context): array
|
||||
{
|
||||
return DB::table('invoices')->where('school_id', $context->schoolId)->whereIn('status', ['open','partial','overpaid'])->get()->map(fn ($row) => (array) $row)->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Repositories;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Finance\Exceptions\InvoiceNotFoundException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class InvoiceRepository
|
||||
{
|
||||
public function findForSchoolOrFail(SchoolContext $context, int $invoiceId, bool $lock = false): array
|
||||
{
|
||||
$query = DB::table('invoices')->where('school_id', $context->schoolId)->where('id', $invoiceId);
|
||||
if ($lock) { $query->lockForUpdate(); }
|
||||
$invoice = $query->first();
|
||||
if (! $invoice) { throw new InvoiceNotFoundException('Invoice not found.'); }
|
||||
return (array) $invoice;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Repositories;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Finance\Exceptions\PaymentNotFoundException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class PaymentRepository
|
||||
{
|
||||
public function findForSchoolOrFail(SchoolContext $context, int $paymentId, bool $lock = false): array
|
||||
{
|
||||
$query = DB::table('payments')->where('school_id', $context->schoolId)->where('id', $paymentId);
|
||||
if ($lock) { $query->lockForUpdate(); }
|
||||
$payment = $query->first();
|
||||
if (! $payment) { throw new PaymentNotFoundException('Payment not found.'); }
|
||||
return (array) $payment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Services;
|
||||
|
||||
use App\Domain\SchoolCore\Finance\Enums\InvoiceStatus;
|
||||
use App\Domain\SchoolCore\Finance\Support\Money;
|
||||
|
||||
final class BalanceCalculator
|
||||
{
|
||||
public function calculate(array $invoice, array $items, array $payments, array $refunds = []): array
|
||||
{
|
||||
$currency = $invoice['currency'] ?? 'USD'; $total = Money::zero($currency);
|
||||
foreach ($items as $item) { $amount = new Money((int) ($item['amount_minor'] ?? 0), $currency); $total = in_array(($item['type'] ?? 'charge'), ['discount','scholarship'], true) ? $total->subtract($amount) : $total->add($amount); }
|
||||
$paid = Money::zero($currency);
|
||||
foreach ($payments as $payment) { if (($payment['status'] ?? '') !== 'reversed') { $paid = $paid->add(new Money((int) ($payment['amount_minor'] ?? 0), $currency)); } }
|
||||
foreach ($refunds as $refund) { $paid = $paid->subtract(new Money((int) ($refund['amount_minor'] ?? 0), $currency)); }
|
||||
$balance = $total->subtract($paid);
|
||||
$status = match (true) { ($invoice['status'] ?? null) === InvoiceStatus::Void->value => InvoiceStatus::Void, $balance->isZero() => InvoiceStatus::Paid, $balance->isNegative() => InvoiceStatus::Overpaid, $paid->isPositive() => InvoiceStatus::Partial, default => InvoiceStatus::Open };
|
||||
return ['total_amount_minor'=>$total->minorAmount(),'paid_amount_minor'=>$paid->minorAmount(),'balance_amount_minor'=>$balance->minorAmount(),'currency'=>$currency,'status'=>$status->value];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Services;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Finance\Contracts\ChargeServiceContract;
|
||||
use App\Domain\SchoolCore\Finance\Contracts\FinanceAuditLoggerContract;
|
||||
use App\Domain\SchoolCore\Finance\DTO\InvoiceItemData;
|
||||
use App\Domain\SchoolCore\Finance\Enums\FinanceAuditAction;
|
||||
use App\Domain\SchoolCore\Finance\Support\FinanceTransactionRunner;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class ChargeService implements ChargeServiceContract
|
||||
{
|
||||
public function __construct(private readonly FinanceTransactionRunner $transactions, private readonly InvoiceService $invoices, private readonly FinanceAuditLoggerContract $audit) {}
|
||||
public function applyItem(SchoolContext $context, int $invoiceId, InvoiceItemData $item): array
|
||||
{
|
||||
return $this->transactions->run(function () use ($context,$invoiceId,$item): array { $itemId=DB::table('invoice_items')->insertGetId(['school_id'=>$context->schoolId,'invoice_id'=>$invoiceId,'description'=>$item->description,'type'=>$item->type,'amount_minor'=>$item->amount->amountMinor,'currency'=>$item->amount->currency,'metadata'=>json_encode($item->metadata, JSON_THROW_ON_ERROR),'created_at'=>now(),'updated_at'=>now()]); $result=$this->invoices->recalculateInvoice($context,$invoiceId); $this->audit->log($context, $item->type === 'discount' ? FinanceAuditAction::DiscountApplied : FinanceAuditAction::ChargeApplied, 'invoice_item', $itemId, null, ['invoice'=>$result]); return $result; });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Services;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Finance\Contracts\FinanceAuditLoggerContract;
|
||||
use App\Domain\SchoolCore\Finance\Enums\FinanceAuditAction;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class FinanceAuditLogger implements FinanceAuditLoggerContract
|
||||
{
|
||||
public function log(SchoolContext $context, FinanceAuditAction $action, string $entityType, int|string $entityId, ?array $before = null, ?array $after = null, ?string $reason = null, ?string $idempotencyKey = null, array $metadata = []): void
|
||||
{
|
||||
DB::table('finance_audit_logs')->insert([
|
||||
'school_id'=>$context->schoolId,'academic_year_id'=>$context->schoolYear,'term_id'=>$context->term,'actor_user_id'=>$context->actorUserId,
|
||||
'actor_role_snapshot'=>json_encode($context->actorRoleIds, JSON_THROW_ON_ERROR),'action'=>$action->value,'entity_type'=>$entityType,'entity_id'=>(string)$entityId,
|
||||
'before_json'=>$before === null ? null : json_encode($before, JSON_THROW_ON_ERROR),'after_json'=>$after === null ? null : json_encode($after, JSON_THROW_ON_ERROR),
|
||||
'reason'=>$reason,'request_id'=>$metadata['request_id'] ?? null,'ip_address'=>$metadata['ip_address'] ?? null,'user_agent'=>$metadata['user_agent'] ?? null,
|
||||
'idempotency_key'=>$idempotencyKey,'created_at'=>now(),'updated_at'=>now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Services;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Finance\Contracts\FinanceAuditLoggerContract;
|
||||
use App\Domain\SchoolCore\Finance\Contracts\InvoiceServiceContract;
|
||||
use App\Domain\SchoolCore\Finance\DTO\CreateInvoiceData;
|
||||
use App\Domain\SchoolCore\Finance\Enums\FinanceAuditAction;
|
||||
use App\Domain\SchoolCore\Finance\Enums\InvoiceStatus;
|
||||
use App\Domain\SchoolCore\Finance\Repositories\InvoiceRepository;
|
||||
use App\Domain\SchoolCore\Finance\Support\FinanceTransactionRunner;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class InvoiceService implements InvoiceServiceContract
|
||||
{
|
||||
public function __construct(private readonly FinanceTransactionRunner $transactions, private readonly InvoiceRepository $invoices, private readonly BalanceCalculator $calculator, private readonly FinanceAuditLoggerContract $audit) {}
|
||||
public function createInvoice(SchoolContext $context, CreateInvoiceData $data): array
|
||||
{
|
||||
return $this->transactions->run(function () use ($context, $data): array {
|
||||
$invoiceId = DB::table('invoices')->insertGetId(['school_id'=>$context->schoolId,'student_id'=>$data->studentId,'guardian_id'=>$data->guardianId,'currency'=>$data->currency,'status'=>InvoiceStatus::Draft->value,'due_date'=>$data->dueDate,'notes'=>$data->notes,'metadata'=>json_encode($data->metadata, JSON_THROW_ON_ERROR),'created_at'=>now(),'updated_at'=>now()]);
|
||||
foreach ($data->items as $item) { DB::table('invoice_items')->insert(['school_id'=>$context->schoolId,'invoice_id'=>$invoiceId,'description'=>$item->description,'type'=>$item->type,'amount_minor'=>$item->amount->amountMinor,'currency'=>$item->amount->currency,'metadata'=>json_encode($item->metadata, JSON_THROW_ON_ERROR),'created_at'=>now(),'updated_at'=>now()]); }
|
||||
$result = $this->recalculateInvoice($context, $invoiceId); $this->audit->log($context, FinanceAuditAction::InvoiceCreated, 'invoice', $invoiceId, null, $result); return $result;
|
||||
});
|
||||
}
|
||||
public function recalculateInvoice(SchoolContext $context, int $invoiceId): array
|
||||
{
|
||||
$invoice = $this->invoices->findForSchoolOrFail($context, $invoiceId, true);
|
||||
$items = DB::table('invoice_items')->where('school_id',$context->schoolId)->where('invoice_id',$invoiceId)->get()->map(fn($r)=>(array)$r)->all();
|
||||
$payments = DB::table('payments')->where('school_id',$context->schoolId)->where('invoice_id',$invoiceId)->get()->map(fn($r)=>(array)$r)->all();
|
||||
$refunds = DB::table('payment_refunds')->where('school_id',$context->schoolId)->where('invoice_id',$invoiceId)->get()->map(fn($r)=>(array)$r)->all();
|
||||
$balance = $this->calculator->calculate($invoice, $items, $payments, $refunds);
|
||||
DB::table('invoices')->where('school_id',$context->schoolId)->where('id',$invoiceId)->update(['total_amount_minor'=>$balance['total_amount_minor'],'paid_amount_minor'=>$balance['paid_amount_minor'],'balance_amount_minor'=>$balance['balance_amount_minor'],'status'=>$balance['status'],'updated_at'=>now()]);
|
||||
return array_merge($invoice, $balance, ['id'=>$invoiceId]);
|
||||
}
|
||||
public function voidInvoice(SchoolContext $context, int $invoiceId, string $reason): array
|
||||
{
|
||||
return $this->transactions->run(function () use ($context,$invoiceId,$reason): array { $before=$this->invoices->findForSchoolOrFail($context,$invoiceId,true); DB::table('invoices')->where('school_id',$context->schoolId)->where('id',$invoiceId)->update(['status'=>InvoiceStatus::Void->value,'void_reason'=>$reason,'updated_at'=>now()]); $after=$this->getInvoice($context,$invoiceId); $this->audit->log($context, FinanceAuditAction::InvoiceVoided, 'invoice', $invoiceId, $before, $after, $reason); return $after; });
|
||||
}
|
||||
public function getInvoice(SchoolContext $context, int $invoiceId): array { return $this->invoices->findForSchoolOrFail($context, $invoiceId); }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Services;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Finance\Contracts\FinanceAuditLoggerContract;
|
||||
use App\Domain\SchoolCore\Finance\Contracts\FinanceFileServiceContract;
|
||||
use App\Domain\SchoolCore\Finance\DTO\PaymentFileData;
|
||||
use App\Domain\SchoolCore\Finance\Enums\FinanceAuditAction;
|
||||
use App\Domain\SchoolCore\Finance\Exceptions\PaymentNotFoundException;
|
||||
use App\Domain\SchoolCore\Finance\Repositories\PaymentRepository;
|
||||
use App\Domain\SchoolCore\Finance\Support\PaymentFileLocator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
final class PaymentFileService implements FinanceFileServiceContract
|
||||
{
|
||||
public function __construct(private readonly PaymentRepository $payments, private readonly PaymentFileLocator $locator, private readonly FinanceAuditLoggerContract $audit) {}
|
||||
public function attachPaymentFile(SchoolContext $context, int $paymentId, PaymentFileData $file): array
|
||||
{
|
||||
$this->payments->findForSchoolOrFail($context,$paymentId); $disk=config('school_context.finance.payment_files_disk','local'); $path=$this->locator->pathFor($context,$paymentId,$file->file->getClientOriginalName()); Storage::disk($disk)->put($path,file_get_contents($file->file->getRealPath())); $id=DB::table('payment_files')->insertGetId(['school_id'=>$context->schoolId,'payment_id'=>$paymentId,'file_type'=>$file->fileType,'storage_disk'=>$disk,'storage_path'=>$path,'original_name'=>$file->file->getClientOriginalName(),'mime_type'=>$file->file->getMimeType(),'metadata'=>json_encode($file->metadata, JSON_THROW_ON_ERROR),'created_at'=>now(),'updated_at'=>now()]); $result=['id'=>$id,'payment_id'=>$paymentId,'storage_path'=>$path]; $this->audit->log($context, FinanceAuditAction::PaymentFileAttached, 'payment_file', $id, null, $result); return $result;
|
||||
}
|
||||
public function downloadPaymentFile(SchoolContext $context, int $paymentId, string $mode): BinaryFileResponse
|
||||
{
|
||||
if(!in_array($mode,['download','view'],true)){throw new \InvalidArgumentException('Invalid payment file mode.');} $this->payments->findForSchoolOrFail($context,$paymentId); $file=DB::table('payment_files')->where('school_id',$context->schoolId)->where('payment_id',$paymentId)->latest('id')->first(); if(!$file){throw new PaymentNotFoundException('Payment file not found.');} $path=(string)$file->storage_path; $this->locator->assertInsideFinanceScope($context,$path); $disk=Storage::disk((string)$file->storage_disk); if(!$disk->exists($path)){throw new PaymentNotFoundException('Payment file not found on disk.');} $this->audit->log($context, FinanceAuditAction::PaymentFileDownloaded, 'payment_file', (int)$file->id, null, ['payment_id'=>$paymentId,'mode'=>$mode]); return response()->download($disk->path($path), $file->original_name ?: basename($path));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Services;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Finance\Contracts\FinanceAuditLoggerContract;
|
||||
use App\Domain\SchoolCore\Finance\Contracts\FinanceFileServiceContract;
|
||||
use App\Domain\SchoolCore\Finance\Contracts\PaymentServiceContract;
|
||||
use App\Domain\SchoolCore\Finance\DTO\EditPaymentData;
|
||||
use App\Domain\SchoolCore\Finance\DTO\PaymentFileData;
|
||||
use App\Domain\SchoolCore\Finance\DTO\RecordPaymentData;
|
||||
use App\Domain\SchoolCore\Finance\Enums\FinanceAuditAction;
|
||||
use App\Domain\SchoolCore\Finance\Enums\PaymentStatus;
|
||||
use App\Domain\SchoolCore\Finance\Repositories\InvoiceRepository;
|
||||
use App\Domain\SchoolCore\Finance\Repositories\PaymentRepository;
|
||||
use App\Domain\SchoolCore\Finance\Support\FinanceIdempotencyKey;
|
||||
use App\Domain\SchoolCore\Finance\Support\FinanceTransactionRunner;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class PaymentService implements PaymentServiceContract
|
||||
{
|
||||
public function __construct(private readonly FinanceTransactionRunner $transactions, private readonly InvoiceRepository $invoices, private readonly PaymentRepository $payments, private readonly InvoiceService $invoiceService, private readonly FinanceAuditLoggerContract $audit, private readonly ?FinanceFileServiceContract $files = null) {}
|
||||
public function recordPayment(SchoolContext $context, RecordPaymentData $data): array
|
||||
{
|
||||
return $this->transactions->run(function () use ($context, $data): array {
|
||||
$payloadHash = FinanceIdempotencyKey::payloadHash(['invoice_id'=>$data->invoiceId,'amount_minor'=>$data->amount->amountMinor,'currency'=>$data->amount->currency,'method'=>$data->paymentMethod->value,'payment_date'=>$data->paymentDate->format('Y-m-d')]);
|
||||
if ($data->idempotencyKey !== null) { $existing = DB::table('finance_idempotency_keys')->where('school_id',$context->schoolId)->where('actor_user_id',$context->actorUserId)->where('operation','payment.record')->where('idempotency_key',$data->idempotencyKey)->first(); if ($existing) { if ($existing->payload_hash !== $payloadHash) { throw new \RuntimeException('Idempotency key reused with different payload.'); } return $this->getPayment($context, (int)$existing->result_entity_id); } }
|
||||
$this->invoices->findForSchoolOrFail($context, $data->invoiceId, true);
|
||||
$paymentId = DB::table('payments')->insertGetId(['school_id'=>$context->schoolId,'invoice_id'=>$data->invoiceId,'student_id'=>$data->studentId,'guardian_id'=>$data->guardianId,'amount_minor'=>$data->amount->amountMinor,'currency'=>$data->amount->currency,'payment_method'=>$data->paymentMethod->value,'payment_date'=>$data->paymentDate->format('Y-m-d'),'reference_number'=>$data->referenceNumber,'notes'=>$data->notes,'status'=>PaymentStatus::Recorded->value,'idempotency_key'=>$data->idempotencyKey,'metadata'=>json_encode($data->metadata, JSON_THROW_ON_ERROR),'created_at'=>now(),'updated_at'=>now()]);
|
||||
if ($data->uploadedFile !== null && $this->files !== null) { $this->files->attachPaymentFile($context, $paymentId, new PaymentFileData($data->uploadedFile)); }
|
||||
$this->invoiceService->recalculateInvoice($context, $data->invoiceId); $result=$this->getPayment($context,$paymentId); $this->audit->log($context, FinanceAuditAction::PaymentRecorded, 'payment', $paymentId, null, $result, null, $data->idempotencyKey);
|
||||
if ($data->idempotencyKey !== null) { DB::table('finance_idempotency_keys')->insert(['school_id'=>$context->schoolId,'actor_user_id'=>$context->actorUserId,'operation'=>'payment.record','idempotency_key'=>$data->idempotencyKey,'payload_hash'=>$payloadHash,'result_entity_type'=>'payment','result_entity_id'=>(string)$paymentId,'created_at'=>now(),'expires_at'=>now()->addDay()]); }
|
||||
return $result;
|
||||
});
|
||||
}
|
||||
public function editPayment(SchoolContext $context, int $paymentId, EditPaymentData $data): array
|
||||
{
|
||||
return $this->transactions->run(function () use ($context,$paymentId,$data): array { $before=$this->payments->findForSchoolOrFail($context,$paymentId,true); $this->invoices->findForSchoolOrFail($context,(int)$before['invoice_id'],true); $updates=['updated_at'=>now(),'status'=>PaymentStatus::Edited->value]; if($data->amount){$updates['amount_minor']=$data->amount->amountMinor;$updates['currency']=$data->amount->currency;} if($data->paymentMethod){$updates['payment_method']=$data->paymentMethod->value;} if($data->paymentDate){$updates['payment_date']=$data->paymentDate->format('Y-m-d');} if($data->referenceNumber!==null){$updates['reference_number']=$data->referenceNumber;} if($data->notes!==null){$updates['notes']=$data->notes;} DB::table('payments')->where('school_id',$context->schoolId)->where('id',$paymentId)->update($updates); $this->invoiceService->recalculateInvoice($context,(int)$before['invoice_id']); $after=$this->getPayment($context,$paymentId); $this->audit->log($context, FinanceAuditAction::PaymentEdited, 'payment', $paymentId, $before, $after, $data->editReason); return $after; });
|
||||
}
|
||||
public function reversePayment(SchoolContext $context, int $paymentId, string $reason): array
|
||||
{
|
||||
if (trim($reason)==='') { throw new \InvalidArgumentException('Reversal reason is required.'); }
|
||||
return $this->transactions->run(function () use ($context,$paymentId,$reason): array { $before=$this->payments->findForSchoolOrFail($context,$paymentId,true); $this->invoices->findForSchoolOrFail($context,(int)$before['invoice_id'],true); DB::table('payments')->where('school_id',$context->schoolId)->where('id',$paymentId)->update(['status'=>PaymentStatus::Reversed->value,'reverse_reason'=>$reason,'updated_at'=>now()]); $this->invoiceService->recalculateInvoice($context,(int)$before['invoice_id']); $after=$this->getPayment($context,$paymentId); $this->audit->log($context, FinanceAuditAction::PaymentReversed, 'payment', $paymentId, $before, $after, $reason); return $after; });
|
||||
}
|
||||
public function getPayment(SchoolContext $context, int $paymentId): array { return $this->payments->findForSchoolOrFail($context, $paymentId); }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Services;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Finance\Contracts\FinanceAuditLoggerContract;
|
||||
use App\Domain\SchoolCore\Finance\Contracts\RefundServiceContract;
|
||||
use App\Domain\SchoolCore\Finance\DTO\RefundPaymentData;
|
||||
use App\Domain\SchoolCore\Finance\Enums\FinanceAuditAction;
|
||||
use App\Domain\SchoolCore\Finance\Enums\PaymentStatus;
|
||||
use App\Domain\SchoolCore\Finance\Repositories\InvoiceRepository;
|
||||
use App\Domain\SchoolCore\Finance\Repositories\PaymentRepository;
|
||||
use App\Domain\SchoolCore\Finance\Support\FinanceTransactionRunner;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class RefundService implements RefundServiceContract
|
||||
{
|
||||
public function __construct(private readonly FinanceTransactionRunner $transactions, private readonly PaymentRepository $payments, private readonly InvoiceRepository $invoices, private readonly InvoiceService $invoiceService, private readonly FinanceAuditLoggerContract $audit) {}
|
||||
public function refundPayment(SchoolContext $context, RefundPaymentData $data): array
|
||||
{
|
||||
return $this->transactions->run(function () use ($context,$data): array { $payment=$this->payments->findForSchoolOrFail($context,$data->paymentId,true); $this->invoices->findForSchoolOrFail($context,(int)$payment['invoice_id'],true); if($data->amount->amountMinor > (int)$payment['amount_minor']){throw new \RuntimeException('Refund cannot exceed payment amount.');} $refundId=DB::table('payment_refunds')->insertGetId(['school_id'=>$context->schoolId,'invoice_id'=>$payment['invoice_id'],'payment_id'=>$data->paymentId,'amount_minor'=>$data->amount->amountMinor,'currency'=>$data->amount->currency,'reason'=>$data->reason,'metadata'=>json_encode($data->metadata, JSON_THROW_ON_ERROR),'created_at'=>now(),'updated_at'=>now()]); DB::table('payments')->where('school_id',$context->schoolId)->where('id',$data->paymentId)->update(['status'=>$data->amount->amountMinor === (int)$payment['amount_minor'] ? PaymentStatus::Refunded->value : PaymentStatus::PartiallyRefunded->value,'updated_at'=>now()]); $this->invoiceService->recalculateInvoice($context,(int)$payment['invoice_id']); $result=['id'=>$refundId,'payment_id'=>$data->paymentId,'amount_minor'=>$data->amount->amountMinor,'currency'=>$data->amount->currency]; $this->audit->log($context, FinanceAuditAction::RefundCreated, 'refund', $refundId, null, $result, $data->reason, $data->idempotencyKey); return $result; });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Support;
|
||||
|
||||
final class FinanceIdempotencyKey
|
||||
{
|
||||
public static function payloadHash(array $payload): string
|
||||
{
|
||||
ksort($payload);
|
||||
return hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Support;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class FinanceTransactionRunner
|
||||
{
|
||||
public function run(callable $callback): mixed
|
||||
{
|
||||
return DB::transaction($callback, attempts: 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Support;
|
||||
|
||||
use App\Domain\SchoolCore\Finance\DTO\MoneyData;
|
||||
use App\Domain\SchoolCore\Finance\Exceptions\InvalidPaymentAmountException;
|
||||
|
||||
final readonly class Money
|
||||
{
|
||||
public function __construct(private int $minorAmount, private string $currency, private int $scale = 2)
|
||||
{
|
||||
if (! preg_match('/^[A-Z]{3}$/', $currency)) { throw new InvalidPaymentAmountException('Invalid currency code.'); }
|
||||
if ($scale < 0 || $scale > 6) { throw new InvalidPaymentAmountException('Invalid money scale.'); }
|
||||
}
|
||||
public static function fromData(MoneyData $data): self { return new self($data->amountMinor, $data->currency, $data->scale); }
|
||||
public static function zero(string $currency = 'USD', int $scale = 2): self { return new self(0, $currency, $scale); }
|
||||
public function add(self $other): self { $this->assertSameCurrency($other); return new self($this->minorAmount + $other->minorAmount, $this->currency, $this->scale); }
|
||||
public function subtract(self $other): self { $this->assertSameCurrency($other); return new self($this->minorAmount - $other->minorAmount, $this->currency, $this->scale); }
|
||||
public function compare(self $other): int { $this->assertSameCurrency($other); return $this->minorAmount <=> $other->minorAmount; }
|
||||
public function equals(self $other): bool { return $this->compare($other) === 0; }
|
||||
public function isZero(): bool { return $this->minorAmount === 0; }
|
||||
public function isPositive(): bool { return $this->minorAmount > 0; }
|
||||
public function isNegative(): bool { return $this->minorAmount < 0; }
|
||||
public function minorAmount(): int { return $this->minorAmount; }
|
||||
public function currency(): string { return $this->currency; }
|
||||
public function scale(): int { return $this->scale; }
|
||||
public function toDecimalString(): string
|
||||
{
|
||||
$negative = $this->minorAmount < 0; $absolute = abs($this->minorAmount);
|
||||
if ($this->scale === 0) { return ($negative ? '-' : '') . (string) $absolute; }
|
||||
$factor = 10 ** $this->scale;
|
||||
return ($negative ? '-' : '') . intdiv($absolute, $factor) . '.' . str_pad((string) ($absolute % $factor), $this->scale, '0', STR_PAD_LEFT);
|
||||
}
|
||||
private function assertSameCurrency(self $other): void
|
||||
{
|
||||
if ($this->currency !== $other->currency || $this->scale !== $other->scale) { throw new InvalidPaymentAmountException('Currency or scale mismatch.'); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SchoolCore\Finance\Support;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
|
||||
final class PaymentFileLocator
|
||||
{
|
||||
public function pathFor(SchoolContext $context, int $paymentId, string $filename): string
|
||||
{
|
||||
return sprintf('schools/%d/finance/payments/%d/%s', $context->schoolId, $paymentId, basename($filename));
|
||||
}
|
||||
public function assertInsideFinanceScope(SchoolContext $context, string $path): void
|
||||
{
|
||||
if (! str_starts_with($path, sprintf('schools/%d/finance/', $context->schoolId))) { throw new \RuntimeException('Payment file path is outside the finance scope.'); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user