update project

This commit is contained in:
root
2026-05-30 01:11:35 -04:00
parent 3a0628ecc7
commit 2225f6bc72
9743 changed files with 1122482 additions and 59 deletions
@@ -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 [];
}
}
+3
View File
@@ -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.'); }
}
}