diff --git a/app/Http/Controllers/Api/Finance/Payment/PaymentCancellationApprovalController.php b/app/Http/Controllers/Api/Finance/Payment/PaymentCancellationApprovalController.php new file mode 100644 index 000000000..02167d11d --- /dev/null +++ b/app/Http/Controllers/Api/Finance/Payment/PaymentCancellationApprovalController.php @@ -0,0 +1,183 @@ +isCancellationRequestStillValid($payment); + if ($request->has('token')) { + // approve from email + $approvalBy = $request->get('approver_id'); + } else { + // Jika Role Bukan Super Admin dan tidak memiliki akses approval maka mengirimkan pesan eror + $payment->isHaveAccessToDelete(); + $approvalBy = auth()->user()->id; + } + + DB::connection('tenant')->transaction(function () use ($payment, $approvalBy) { + // ### If approve success then + // Status form cash out akan menjadi cancelled + $payment->form->cancellation_approval_by = $approvalBy; + $payment->form->cancellation_approval_at = now(); + $payment->form->cancellation_status = 1; + $payment->form->save(); + + // Jumlah saldo cash account / cash advance/ biaya yang dipilih akan bertambah sebesar data yang dihapus + // Pengembalian dana cash advance & status formnya menjadi pending + $amountPaidByCashAdvance = 0; + if ($payment->cashAdvance) { + $cashAdvancePayment = $payment->cashAdvance; + $cashAdvance = $cashAdvancePayment->cashAdvance; + $amountPaidByCashAdvance = $payment->cashAdvance->amount; + + $cashAdvance->amount_remaining += $amountPaidByCashAdvance; + $cashAdvance->save(); + + $cashAdvance->form->done = 0; + $cashAdvance->form->save(); + + $activity = 'Payment Refund (' . $payment->form->number . ')'; + $this->writeHistory($cashAdvance, $approvalBy, $activity); + } + + // Pengembalian dana account + $amountPaidByAccount = $payment->amount - $amountPaidByCashAdvance; + $journal = new Journal; + $journal->form_id = $payment->form->id; + $journal->journalable_type = $payment->paymentable_type; + $journal->journalable_id = $payment->paymentable_id; + $journal->chart_of_account_id = $payment->payment_account_id; + if ($payment->disbursed) { + $journal->debit = $amountPaidByAccount; + } else { + $journal->credit = $amountPaidByAccount; + } + $journal->save(); + + foreach ($payment->details as $paymentDetail) { + $journal = new Journal; + $journal->form_id = $payment->form->id; + $journal->form_id_reference = optional(optional($paymentDetail->referenceable)->form)->id; + $journal->journalable_type = $payment->paymentable_type; + $journal->journalable_id = $payment->paymentable_id; + $journal->notes = $paymentDetail->notes; + $journal->chart_of_account_id = $paymentDetail->chart_of_account_id; + if (!$payment->disbursed) { + $journal->credit = $paymentDetail->amount; + } else { + $journal->debit = $paymentDetail->amount; + } + $journal->save(); + + // Status form reference jadi pending + $paymentDetail->referenceable->form->done = 0; + $paymentDetail->referenceable->form->save(); + } + + // Delete data allocation pada allocation report + $payment->allocationReports()->delete(); + }); + + $payment->load('form'); + return new ApiResource($payment); + } + + /** + * @param Request $request + * @param $id + * @return ApiResource + * @throws ApprovalNotFoundException + * @throws UnauthorizedException + */ + public function reject(Request $request, $id) + { + $payment = Payment::findOrFail($id); + + // ### Reject fail if + $this->isCancellationRequestStillValid($payment); + if ($request->has('token')) { + // reject from email + $approvalBy = $request->get('approver_id'); + $request->merge(['reason' => 'Rejected by email']); + } else { + $request->validate([ + 'reason' => 'required' + ]); + // Jika Role Bukan Super Admin / Pihak yang dipilih utk approval maka akan mengirimkan pesan eror + // Jika tidak memiliki akses approval pada payment order maka akan mengirimkan pesan eror + $payment->isHaveAccessToDelete(); + $approvalBy = auth()->user()->id; + } + + DB::connection('tenant')->transaction(function () use ($payment, $request, $approvalBy) { + // ### If reject success then + // Update status approval form menjadi rejected + $payment->form->approval_status = -1; + + // Update status form status menjadi pending + $payment->form->done = 0; + + $payment->form->cancellation_approval_by = $approvalBy; + $payment->form->cancellation_approval_at = now(); + $payment->form->cancellation_approval_reason = $request->get('reason'); + $payment->form->cancellation_status = -1; + + $payment->form->save(); + }); + + $payment->form = $payment->form; + return new ApiResource($payment); + } + + public function isCancellationRequestStillValid($payment) + { + // is cancellation request already approved? + $cancellationStatus = $payment->form->cancellation_status; + if ($cancellationStatus == 1) { + throw new PointException('Form cancellation already approved'); + } + // is cancellation request already rejected? + // if ($cancellationStatus == -1) { + // throw new PointException('Form cancellation already rejected'); + // } + } + + // $reference = cash advance, + public function writeHistory($reference, int $userId, string $activity) + { + $history = new UserActivity; + + $history->table_type = $reference::$morphName; + $history->table_id = $reference->id; + $history->number = $reference->form->number; + $history->user_id = $userId; + $history->date = convert_to_local_timezone(date('Y-m-d H:i:s')); + $history->activity = $activity; + + $history->save(); + } +} diff --git a/app/Http/Controllers/Api/Finance/Payment/PaymentController.php b/app/Http/Controllers/Api/Finance/Payment/PaymentController.php index 94d47160b..fc46cac4a 100644 --- a/app/Http/Controllers/Api/Finance/Payment/PaymentController.php +++ b/app/Http/Controllers/Api/Finance/Payment/PaymentController.php @@ -7,10 +7,19 @@ use App\Http\Requests\Finance\Payment\Payment\UpdatePaymentRequest; use App\Http\Resources\ApiCollection; use App\Http\Resources\ApiResource; +use App\Mail\Finance\Payment\PaymentCancellationApprovalRequest; +use App\Model\Auth\Role; use App\Model\Finance\Payment\Payment; +use App\Model\Finance\PaymentOrder\PaymentOrder; +use App\Model\Master\User; +use App\Model\Purchase\PurchaseDownPayment\PurchaseDownPayment; +use App\Model\Token; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Mail; use Throwable; class PaymentController extends Controller @@ -23,7 +32,7 @@ class PaymentController extends Controller */ public function index(Request $request) { - $payment = Payment::from(Payment::getTableName().' as '.Payment::$alias)->eloquentFilter($request); + $payment = Payment::from(Payment::getTableName() . ' as ' . Payment::$alias)->eloquentFilter($request); $payment = Payment::joins($payment, $request->get('join')); @@ -47,7 +56,8 @@ public function store(StorePaymentRequest $request) ->load('form') ->load('paymentable') ->load('details.allocation') - ->load('details.referenceable.form'); + ->load('details.referenceable.form') + ->load('cashAdvance.cashAdvance.form'); return new ApiResource($payment); }); @@ -83,7 +93,7 @@ public function update(UpdatePaymentRequest $request, $id) $payment->form->archive(); foreach ($payment->details as $paymentDetail) { - if (! $paymentDetail->isDownPayment()) { + if (!$paymentDetail->isDownPayment()) { $reference = $paymentDetail->referenceable; $reference->remaining += $paymentDetail->amount; $reference->save(); @@ -114,23 +124,123 @@ public function update(UpdatePaymentRequest $request, $id) */ public function destroy(Request $request, $id) { + $request->validate([ + 'reason' => 'required' + ]); + $payment = Payment::findOrFail($id); $payment->isAllowedToDelete(); - $response = $payment->requestCancel($request); - - if (! $response) { - foreach ($payment->details as $paymentDetail) { - if (! $paymentDetail->isDownPayment()) { - $reference = $paymentDetail->referenceable; - $reference->remaining += $payment->amount; - $reference->save(); - $reference->form->done = false; - $reference->form->save(); + DB::connection('tenant')->transaction(function () use ($payment, $request) { + $payment->requestCancel($request); + + // Kirim notifikasi by program & email + $superAdminRole = Role::where('name', 'super admin')->first(); + $emailUsers = User::whereHas('roles', function (Builder $query) use ($superAdminRole) { + $query->where('role_id', '=', $superAdminRole->id); + })->get(); + + foreach ($emailUsers as $recipient) { + // create token based on request_approval_to + $token = Token::where('user_id', $recipient->id)->first(); + + if (!$token) { + $token = new Token([ + 'user_id' => $recipient->id, + 'token' => md5($recipient->email . '' . now()), + ]); + $token->save(); } + + Mail::to([ + $recipient->email, + ])->queue(new PaymentCancellationApprovalRequest( + $payment, + $recipient, + $payment->form, + $token->token + )); } - } + + // if (!$response) { + // foreach ($payment->details as $paymentDetail) { + // if (!$paymentDetail->isDownPayment()) { + // $reference = $paymentDetail->referenceable; + // $reference->remaining += $payment->amount; + // $reference->save(); + // $reference->form->done = false; + // $reference->form->save(); + // } + // } + // } + }); return response()->json([], 204); } + + public function getReferences(Request $request) + { + // Split request filter for each reference type + $paymentOrderRequest = new Request(); + $downPaymentRequest = new Request(); + $paymentOrderString = 'paymentorder'; + $downPaymentString = 'downpayment'; + foreach ($request->all() as $key => $value) { + if (in_array($key, ['limit', 'page'])) { + $paymentOrderRequest->merge([ + $key => $value + ]); + $downPaymentRequest->merge([ + $key => $value + ]); + continue; + } + $explodedKey = explode('_', $key); + + switch ($explodedKey[0]) { + case $paymentOrderString: + $keyAttribute = substr($key, strlen($paymentOrderString) + 1); //+1 for _ + $paymentOrderRequest->merge([ + $keyAttribute => $value + ]); + break; + + case $downPaymentString: + $keyAttribute = substr($key, strlen($downPaymentString) + 1); //+1 for _ + $downPaymentRequest->merge([ + $keyAttribute => $value + ]); + break; + + default: + # code... + break; + } + } + + $references = new Collection(); + + $paymentOrders = PaymentOrder::from(PaymentOrder::getTableName() . ' as ' . PaymentOrder::$alias)->eloquentFilter($paymentOrderRequest); + $paymentOrders = PaymentOrder::joins($paymentOrders, $paymentOrderRequest->get('join'))->get(); + $references = $references->concat($paymentOrders); + + $downPayments = PurchaseDownPayment::from(PurchaseDownPayment::getTableName() . ' as ' . PurchaseDownPayment::$alias)->eloquentFilter($downPaymentRequest); + $downPayments = PurchaseDownPayment::joins($downPayments, $downPaymentRequest->get('join'))->get(); + $references = $references->concat($downPayments); + + $references = $references->sortBy($request->get('sort_by')); + $paginatedReferences = paginate_collection($references, $request->get('limit'), $request->get('page')); + + return new ApiCollection($paginatedReferences); + } + + public function getPaymentables(Request $request) + { + $paymentables = Payment::from(Payment::getTableName() . ' as ' . Payment::$alias) + ->select(['paymentable_id', 'paymentable_type', 'paymentable_name']) + ->eloquentFilter($request); + $paymentables = pagination($paymentables, $request->get('limit')); + + return new ApiCollection($paymentables); + } } diff --git a/app/Http/Requests/Finance/Payment/Payment/StorePaymentRequest.php b/app/Http/Requests/Finance/Payment/Payment/StorePaymentRequest.php index 034cca592..38aef780a 100644 --- a/app/Http/Requests/Finance/Payment/Payment/StorePaymentRequest.php +++ b/app/Http/Requests/Finance/Payment/Payment/StorePaymentRequest.php @@ -2,8 +2,10 @@ namespace App\Http\Requests\Finance\Payment\Payment; +use App\Exceptions\PointException; use App\Http\Requests\ValidationRule; use App\Model\Accounting\ChartOfAccount; +use App\Model\Finance\CashAdvance\CashAdvance; use App\Model\Finance\Payment\PaymentDetail; use App\Model\Master\Allocation; use Illuminate\Foundation\Http\FormRequest; @@ -35,6 +37,7 @@ public function rules() 'paymentable_id' => 'required|integer|min:0', 'paymentable_type' => 'required|string', 'details' => 'required|array', + 'notes' => 'nullable|max:255' ]; $rulesPaymentDetail = [ @@ -43,8 +46,8 @@ public function rules() 'details.*.allocation_id' => ValidationRule::foreignKeyNullable(Allocation::getTableName()), 'details.*.referenceable_type' => [ function ($attribute, $value, $fail) { - if (! PaymentDetail::referenceableIsValid($value)) { - $fail($attribute.' is invalid'); + if (!PaymentDetail::referenceableIsValid($value)) { + $fail($attribute . ' is invalid'); } }, ], @@ -52,4 +55,34 @@ function ($attribute, $value, $fail) { return array_merge($rulesForm, $rulesPayment, $rulesPaymentDetail); } + + public function withValidator($validator) + { + $validator->after(function ($validator) { + // Cash out/bank out + if (request()->get('disbursed') == 1) { + $amountCashAdvance = 0; + $needToPayFromAccount = request()->get('amount'); + + if ((request()->filled('cash_advance.id'))) { + $cashAdvance = CashAdvance::find(request()->get('cash_advance')['id']); + $amountCashAdvance = $cashAdvance->amount_remaining; + if ($amountCashAdvance > $needToPayFromAccount) { + // All covered by cash advance + $needToPayFromAccount = 0; + } else { + $needToPayFromAccount = request()->get('amount') - $amountCashAdvance; + } + } + + if ($needToPayFromAccount > 0) { + // Check balance payment account + $balancePaymentAccount = ChartOfAccount::find(request()->get('payment_account_id'))->total(date('Y-m-d 23:59:59')); + if ($balancePaymentAccount < $needToPayFromAccount) { + throw new PointException('Balance is not enough'); + } + } + } + }); + } } diff --git a/app/Mail/Finance/Payment/PaymentCancellationApprovalRequest.php b/app/Mail/Finance/Payment/PaymentCancellationApprovalRequest.php new file mode 100644 index 000000000..1f2f6b9e8 --- /dev/null +++ b/app/Mail/Finance/Payment/PaymentCancellationApprovalRequest.php @@ -0,0 +1,49 @@ +payment = $payment; + $this->approver = $approver; + $this->form = $form; + $this->token = $token; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + return $this->subject('Cancellation Approval Email') + ->view('emails.finance.payment.cancellation-approval', [ + 'payment' => $this->payment, + 'approverId' => $this->approver->id, + 'fullName' => $this->approver->getFullNameAttribute(), + 'form' => $this->form, + 'token' => $this->token, + 'cashAdvancePayment' => $this->payment->cashAdvance + ]); + } +} diff --git a/app/Model/AllocationReport.php b/app/Model/AllocationReport.php index 3c3698bc6..fe3b53347 100644 --- a/app/Model/AllocationReport.php +++ b/app/Model/AllocationReport.php @@ -20,4 +20,12 @@ public function form() { return $this->belongsTo(Form::class); } + + /** + * Get all of the owning formable models. + */ + public function allocationable() + { + return $this->morphTo(); + } } diff --git a/app/Model/Finance/CashAdvance/CashAdvancePayment.php b/app/Model/Finance/CashAdvance/CashAdvancePayment.php new file mode 100644 index 000000000..e691eff5a --- /dev/null +++ b/app/Model/Finance/CashAdvance/CashAdvancePayment.php @@ -0,0 +1,40 @@ + 'double' + ]; + + public function cashAdvance() + { + return $this->belongsTo(CashAdvance::class); + } + + public function payment() + { + return $this->belongsTo(Payment::class); + } +} diff --git a/app/Model/Finance/Payment/Payment.php b/app/Model/Finance/Payment/Payment.php index 13099fe38..6cff1f1b4 100644 --- a/app/Model/Finance/Payment/Payment.php +++ b/app/Model/Finance/Payment/Payment.php @@ -4,9 +4,12 @@ use App\Exceptions\BranchNullException; use App\Exceptions\PointException; +use App\Exceptions\UnauthorizedException; use App\Model\Accounting\Journal; +use App\Model\AllocationReport; use App\Model\Finance\PaymentOrder\PaymentOrder; use App\Model\Finance\CashAdvance\CashAdvance; +use App\Model\Finance\CashAdvance\CashAdvancePayment; use App\Model\Form; use App\Model\Purchase\PurchaseDownPayment\PurchaseDownPayment; use App\Model\Sales\PaymentCollection\PaymentCollection; @@ -14,7 +17,6 @@ use App\Model\TransactionModel; use App\Traits\Model\Finance\PaymentJoin; use App\Traits\Model\Finance\PaymentRelation; -use DB; class Payment extends TransactionModel { @@ -48,6 +50,25 @@ public function isAllowedToUpdate() public function isAllowedToDelete() { // TODO isAllowed to delete? + + // Forbidden to delete, + // Jika memiliki permission + $this->isHaveAccessToDelete(); + + // Jika pada periode yang akan didelete sudah dilakukan close book maka akan mengirimkan pesan eror + // Wait for next release feature + } + + // Check if auth user have access to delete payment + public function isHaveAccessToDelete() + { + $authUserId = auth()->user()->id; + // Only super admin & approver referenceable can delete + $isSuperAdmin = tenant($authUserId)->hasRole('super admin'); + $isApproverReferenceable = $this->details()->first()->referenceable->form->approval_by; + if ((!$isSuperAdmin) && ($authUserId != $isApproverReferenceable)) { + throw new UnauthorizedException(); + } } public static function create($data) @@ -58,7 +79,7 @@ public static function create($data) $payment->paymentable_name = $data['paymentable_name'] ?? $payment->paymentable->name; $paymentDetails = self::mapPaymentDetails($data); - + // Reference Payment Order if (isset($data['referenceable_type']) && $data['referenceable_type'] == 'PaymentOrder') { $paymentOrder = PaymentOrder::find($data['referenceable_id']); @@ -151,6 +172,38 @@ public static function create($data) ); $form->save(); + $payByCashAdvance = 0; + // If user select cash advance + if (isset($data['cash_advance']['id']) && $data['cash_advance']['id'] != null) { + $cashAdvance = CashAdvance::find($data['cash_advance']['id']); + + $payByCashAdvance = $cashAdvance->amount_remaining; + + if ($cashAdvance->amount_remaining > $payment->amount) { + $payByCashAdvance = $payment->amount; + $cashAdvance->amount_remaining = $cashAdvance->amount_remaining - $payment->amount; + } + + CashAdvancePayment::create([ + 'cash_advance_id' => $cashAdvance->id, + 'payment_id' => $payment->id, + 'amount' => $payByCashAdvance + ]); + + if ($cashAdvance->amount_remaining == 0 || $data['cash_advance']['close'] == true) { + $cashAdvance->amount_remaining = 0; + $cashAdvance->form->done = 1; + $cashAdvance->form->save(); + } + $cashAdvance->save(); + + $data['activity'] = ucfirst(strtolower($cashAdvance->payment_type)) . ' Out Withdrawal (' . $form->number . ')'; + CashAdvance::mapHistory($cashAdvance, $data); + } + + // Save allocation reports + self::mapAllocationReports($data, $payment); + // Reference Cash Advance if (isset($data['referenceable_type']) && $data['referenceable_type'] == 'CashAdvance') { $cashAdvance = CashAdvance::find($data['referenceable_id']); @@ -159,13 +212,13 @@ public static function create($data) } $cashAdvance->payments()->attach($payment->id); $cashAdvance->amount_remaining = $cashAdvance->amount_remaining - $payment->amount; - if($cashAdvance->amount_remaining == 0) { + if ($cashAdvance->amount_remaining == 0) { $cashAdvance->form->done = 1; $cashAdvance->form->save(); } $cashAdvance->save(); - $data['activity'] = ucfirst(strtolower($cashAdvance->payment_type)).' Out Withdrawal ('.$form->number.')'; + $data['activity'] = ucfirst(strtolower($cashAdvance->payment_type)) . ' Out Withdrawal (' . $form->number . ')'; CashAdvance::mapHistory($cashAdvance, $data); } @@ -173,7 +226,7 @@ public static function create($data) if ($isPaymentCollection) { self::updateJournalPaymentCollection($payment, $journalsPaymentCollection); } else { - error_log('false'); + // error_log('false'); self::updateJournal($payment); } @@ -192,6 +245,22 @@ private static function mapPaymentDetails($data) }, $data['details']); } + private static function mapAllocationReports($data, $payment) + { + return array_map(function ($detail) use ($data, $payment) { + if (isset($detail['allocation_id'])) { + $allocationReport = new AllocationReport; + $allocationReport->allocation_id = $detail['allocation_id']; + $allocationReport->allocationable_id = $payment->id; + $allocationReport->allocationable_type = $payment::$morphName; + $allocationReport->form_id = $payment->form->id; + $allocationReport->notes = $detail['notes']; + + $allocationReport->save(); + } + }, $data['details']); + } + private static function calculateAmount($paymentDetails) { return array_reduce($paymentDetails, function ($carry, $detail) { @@ -209,12 +278,12 @@ private static function mapPaymentCollectionJournals($payment, $data) $paymentDetail->fill($detail); $paymentDetail->referenceable_type = $data['referenceable_type'] ?? null; $paymentDetail->referenceable_id = $data['referenceable_id'] ?? null; - + $journal = new Journal; $journal->form_id_reference = optional(optional($paymentDetail->referenceable)->form)->id; $journal->notes = $paymentDetail->notes; $journal->chart_of_account_id = $paymentDetail->chart_of_account_id; - + if ($detail['payment_collection_type'] === 'SalesDownPayment') { $journal->debit = $paymentDetail->amount; $journals['debit'][] = $journal; @@ -271,19 +340,19 @@ private static function generateFormNumber($payment, $number, $increment) */ private static function getLastPaymentIncrement($payment, $incrementGroup) { - $lastPayment = self::from(self::getTableName().' as '.self::$alias) + $lastPayment = self::from(self::getTableName() . ' as ' . self::$alias) ->joinForm() ->where('form.increment_group', $incrementGroup) ->whereNotNull('form.number') - ->where(self::$alias.'.payment_type', $payment->payment_type) - ->where(self::$alias.'.disbursed', $payment->disbursed) + ->where(self::$alias . '.payment_type', $payment->payment_type) + ->where(self::$alias . '.disbursed', $payment->disbursed) ->with('form') ->orderBy('form.increment', 'desc') - ->select(self::$alias.'.*') + ->select(self::$alias . '.*') ->first(); $increment = 1; - if (! empty($lastPayment)) { + if (!empty($lastPayment)) { $increment += $lastPayment->form->increment; } @@ -309,7 +378,7 @@ private static function updateJournal($payment) $journal->journalable_type = $payment->paymentable_type; $journal->journalable_id = $payment->paymentable_id; $journal->chart_of_account_id = $payment->payment_account_id; - if (! $payment->disbursed) { + if (!$payment->disbursed) { $journal->debit = $payment->amount; } else { $journal->credit = $payment->amount; @@ -324,7 +393,7 @@ private static function updateJournal($payment) $journal->journalable_id = $payment->paymentable_id; $journal->notes = $paymentDetail->notes; $journal->chart_of_account_id = $paymentDetail->chart_of_account_id; - if (! $payment->disbursed) { + if (!$payment->disbursed) { $journal->credit = $paymentDetail->amount; } else { $journal->debit = $paymentDetail->amount; @@ -355,6 +424,6 @@ private static function updateJournalPaymentCollection($payment, $journalsPaymen $journal->journalable_type = $payment->paymentable_type; $journal->journalable_id = $payment->paymentable_id; $journal->save(); - } + } } } diff --git a/app/Traits/Model/Finance/PaymentJoin.php b/app/Traits/Model/Finance/PaymentJoin.php index b180e0841..56e149471 100644 --- a/app/Traits/Model/Finance/PaymentJoin.php +++ b/app/Traits/Model/Finance/PaymentJoin.php @@ -59,6 +59,10 @@ public static function joins($query, $joins) }); } + if (in_array('cashAdvances', $joins)) { + $query = $query->with(['cashAdvances']); + } + return $query; } } diff --git a/app/Traits/Model/Finance/PaymentRelation.php b/app/Traits/Model/Finance/PaymentRelation.php index 34c727f9c..dfd252c28 100644 --- a/app/Traits/Model/Finance/PaymentRelation.php +++ b/app/Traits/Model/Finance/PaymentRelation.php @@ -3,6 +3,9 @@ namespace App\Traits\Model\Finance; use App\Model\Accounting\ChartOfAccount; +use App\Model\AllocationReport; +use App\Model\Finance\CashAdvance\CashAdvance; +use App\Model\Finance\CashAdvance\CashAdvancePayment; use App\Model\Finance\Payment\PaymentDetail; use App\Model\Form; use App\Model\Purchase\PurchaseOrder\PurchaseOrder; @@ -45,4 +48,22 @@ public function purchaseOrders() ->orWhere(Form::getTableName('cancellation_status'), '!=', '1'); }); } + + /** + * Get all related cash advances + */ + public function cashAdvances() + { + return $this->belongsToMany(CashAdvance::class, 'cash_advance_payment', 'payment_id', 'cash_advance_id'); + } + + public function cashAdvance() + { + return $this->hasOne(CashAdvancePayment::class); + } + + public function allocationReports() + { + return $this->morphMany(AllocationReport::class, 'allocationable'); + } } diff --git a/app/helpers.php b/app/helpers.php index 3c6913b2a..2b2bd7d54 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -2,6 +2,9 @@ use App\Model\SettingJournal; use Carbon\Carbon; +use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Pagination\Paginator; +use Illuminate\Support\Collection; use Illuminate\Support\Str; if (! function_exists('log_object')) { @@ -363,3 +366,26 @@ function response_error($error) return response (['code' => $code, 'message' => $message], $httpCode); } } + +if (! function_exists('paginate_collection')) { + /** + * Paginate collection (not from query). + * + * @param $collection + * @param null $limit + * @return string + */ + function paginate_collection($collection, $limit = null, $page = null, $options = []) + { + if (! $limit) { + return $collection->paginate(100); + } + + // limit call maximum 1000 item per page + $limit = $limit > 1000 ? 1000 : $limit; + + $page = $page ?: (Paginator::resolveCurrentPage() ?: 1); + $collection = $collection instanceof Collection ? $collection : Collection::make($collection); + return new LengthAwarePaginator($collection->forPage($page, $limit), $collection->count(), $limit, $page, $options); + } +} \ No newline at end of file diff --git a/database/migrations/tenant/2022_11_04_052031_alter_cash_advance_payment_add_amount_column.php b/database/migrations/tenant/2022_11_04_052031_alter_cash_advance_payment_add_amount_column.php new file mode 100644 index 000000000..100da4556 --- /dev/null +++ b/database/migrations/tenant/2022_11_04_052031_alter_cash_advance_payment_add_amount_column.php @@ -0,0 +1,32 @@ +unsignedDecimal('amount', 65, 30); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('cash_advance_payment', function (Blueprint $table) { + $table->dropColumn(['amount']); + }); + } +} diff --git a/resources/views/emails/finance/payment/cancellation-approval.blade.php b/resources/views/emails/finance/payment/cancellation-approval.blade.php new file mode 100644 index 000000000..8695e3e49 --- /dev/null +++ b/resources/views/emails/finance/payment/cancellation-approval.blade.php @@ -0,0 +1,107 @@ +@extends('emails.template') + +@section('content') +
Cancellation Approval Email
+
+
+ Hello Mrs/Mr/Ms {{ $fullName }}, +
+ You Have an approval for Payment Cancellation. we would like to details as follows: +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Form Number: {{ $payment->form->number ?: '-' }}
Form Date: {{ date('d F Y', strtotime($payment->form->date)) ?: '-' }}
Form Reference: {{ $payment->details()->first()->referenceable->form->number ?: '-' }}
Cash Advance: {{ $cashAdvancePayment ? $cashAdvancePayment->cashAdvance->form->number : '-' }}
Amount Cash Advance: {{ $cashAdvancePayment ? $cashAdvancePayment->amount : '-' }}
Cash Account: {{ $payment->paymentAccount->label }}
Person: {{ $payment->paymentable_name }}
Notes: {{ $payment->form->notes ?: '-' }}
+
+
+ + + + + + + + + + + + @foreach($payment->details as $i => $detail) + + + + + + + + @php ($i++) + @endforeach + +
NoAccountNotesAmountAllocation
+ {{ ++$i }} + + {{ $detail->chartOfAccount->label }} + + {{ $detail->notes }} + + {{ $detail->amount }} + + {{ $detail->allocation ? $detail->allocation->name : '-' }} +
+
+
+ + Check + + + Approve + + + Reject + +
+
+
+@stop \ No newline at end of file diff --git a/routes/api/finance.php b/routes/api/finance.php index cfb3a6488..dc18b4d2e 100644 --- a/routes/api/finance.php +++ b/routes/api/finance.php @@ -1,6 +1,12 @@ namespace('Finance')->group(function () { + Route::prefix('payments')->namespace('Payment')->group(function (){ + Route::get('get-paymentables', 'PaymentController@getPaymentables'); + Route::get('get-references', 'PaymentController@getReferences'); + Route::post('{id}/cancellation-approve', 'PaymentCancellationApprovalController@approve'); + Route::post('{id}/cancellation-reject', 'PaymentCancellationApprovalController@reject'); + }); Route::apiResource('payments', 'Payment\\PaymentController'); Route::post('payment-orders/{id}/approve', 'Payment\\PaymentOrderApprovalController@approve'); Route::post('payment-orders/{id}/reject', 'Payment\\PaymentOrderApprovalController@reject'); diff --git a/tests/Feature/Http/Finance/Cash/CashOutCancellationTest.php b/tests/Feature/Http/Finance/Cash/CashOutCancellationTest.php new file mode 100644 index 000000000..2c3b69e7d --- /dev/null +++ b/tests/Feature/Http/Finance/Cash/CashOutCancellationTest.php @@ -0,0 +1,95 @@ +createPaymentOrder(); + $data = $this->getDataPayment($paymentOrder); + + $this->json('POST', self::$path . '/payments', $data, $this->headers); + + return Payment::orderBy('id', 'desc')->first(); + } + + // Success request to delete + /** @test */ + public function success_request_to_delete() + { + $data = [ + 'reason' => 'Please delete this form because...' + ]; + + $payment = $this->createPayment(); + + $response = $this->json('DELETE', self::$path . '/payments/' . $payment->id, $data, $this->headers); + $response->assertStatus(204); + } + + // Fail request to delete because didn't insert reason + /** @test */ + public function fail_request_to_delete() + { + $payment = $this->createPayment(); + + $response = $this->json('DELETE', self::$path . '/payments/' . $payment->id, $this->headers); + $response->assertStatus(422); + } + + public function getPaymentReadyToCancellation() + { + $data = [ + 'reason' => 'Please delete this form because...' + ]; + + $payment = $this->createPayment(); + $this->json('DELETE', self::$path . '/payments/' . $payment->id, $data, $this->headers); + + return Payment::orderBy('id', 'desc')->first(); + } + + // Success approve to delete + /** @test */ + public function success_approve_to_delete() + { + $payment = $this->getPaymentReadyToCancellation(); + + $response = $this->json('POST', self::$path . '/payments/' . $payment->id . '/cancellation-approve', $this->headers); + $response->assertStatus(200); + } + + // Success reject to delete + /** @test */ + public function success_reject_to_delete() + { + $data = [ + 'reason' => 'Sorry, this form can\'t be deleted' + ]; + + $payment = $this->getPaymentReadyToCancellation(); + + $response = $this->json('POST', self::$path . '/payments/' . $payment->id . '/cancellation-reject', $data, $this->headers); + $response->assertStatus(200); + } + + // Fail reject to delete + /** @test */ + public function fail_reject_to_delete() + { + $payment = $this->getPaymentReadyToCancellation(); + + $response = $this->json('POST', self::$path . '/payments/' . $payment->id . '/cancellation-reject', $this->headers); + $response->assertStatus(422); + } +} diff --git a/tests/Feature/Http/Finance/Cash/CashOutSetup.php b/tests/Feature/Http/Finance/Cash/CashOutSetup.php new file mode 100644 index 000000000..4f13190b5 --- /dev/null +++ b/tests/Feature/Http/Finance/Cash/CashOutSetup.php @@ -0,0 +1,234 @@ +signIn(); + $this->setRole(); + $this->setProject(); + $this->importChartOfAccount(); + } + + private function importChartOfAccount() + { + Excel::import(new ChartOfAccountImport(), storage_path('template/chart_of_accounts_manufacture.xlsx')); + + $this->artisan('db:seed', [ + '--database' => 'tenant', + '--class' => 'SettingJournalSeeder', + '--force' => true, + ]); + } + + public function getChartOfAccountCash() + { + return ChartOfAccount::whereHas('type', function ($query) { + $query->where('name', 'CASH'); + })->first(); + } + + public function createPaymentOrder($onlyGetData = false) + { + $countDetails = rand(1, 5); + + $coas = ChartOfAccount::whereHas('type', function ($query) { + $query->whereIn('name', ['DIRECT EXPENSE', 'OTHER EXPENSE', 'OTHER CURRENT ASSET', 'INCOME TAX RECEIVABLE', 'INCOME TAX PAYABLE', 'OTHER ACCOUNT RECEIVABLE', 'OTHER CURRENT LIABILITY', 'LIABILITAS JANGKA PANJANG', 'FACTORY OVERHEAD COST']); + })->inRandomOrder()->limit($countDetails)->get(); + $details = []; + for ($i = 0; $i < $countDetails; $i++) { + ${'coa' . $i} = $coas->skip($i)->first(); + $details[$i] = [ + 'chart_of_account_id' => ${'coa' . $i}->id, + 'chart_of_account_name' => ${'coa' . $i}->label, + 'amount' => rand(1, 100) * 1_000, + 'allocation_id' => factory(Allocation::class)->create()->id + ]; + } + + $paymentable = factory(Customer::class)->create(); + $form = [ + 'payment_type' => 'cash', + 'due_date' => Carbon::create(date('Y'), date('m'), rand(1, 28)), + 'paymentable_id' => $paymentable->id, + 'paymentable_type' => $paymentable::$morphName, + 'details' => $details + ]; + + if ($onlyGetData) { + return $form; + } + $paymentOrder = PaymentOrder::create($form); + // Approve payment order + $paymentOrder->form->approval_by = auth()->user()->id; + $paymentOrder->form->save(); + + return $paymentOrder; + } + + public function createCashAdvance($amount) + { + // Copied & adjusted from CashAdvanceTest/createDataCashAdvance & CashAdvanceTest/makePaymentCashIn + $account = ChartOfAccount::where('name', 'CASH')->first(); + $account_detail = ChartOfAccount::where('name', 'OTHER INCOME')->first(); + + $employee = factory(Employee::class)->create(); + $user = factory(User::class)->create(); + + // s: insert cash in + $amount_account = rand(10, 100) * 100_000; + $data = [ + 'increment_group' => date('Ym'), + 'date' => date('Y-m-d H:i:s'), + 'due_date' => date('Y-m-d H:i:s'), + 'payment_type' => "cash", + 'payment_account_id' => $account->id, + 'paymentable_id' => $employee->id, + 'paymentable_name' => $employee->name, + 'paymentable_type' => Employee::$morphName, + 'disbursed' => false, + 'notes' => null, + 'amount' => $amount_account, + 'details' => array( + [ + 'chart_of_account_id' => $account_detail->id, + 'amount' => $amount_account, + 'allocation_id' => null, + 'allocation_name' => null, + 'notes' => "Kas" + ] + ) + ]; + $payment = Payment::create($data); + + $form = $payment->form; + $journal = new Journal; + $journal->form_id = $form->id; + $journal->chart_of_account_id = $account->id; + $journal->debit = $amount_account; + $journal->save(); + + //create sample cash advance + $data = [ + 'increment_group' => date('Ym'), + 'date' => date('Y-m-d H:i:s'), + 'payment_type' => 'cash', + 'employee_id' => $employee->id, + 'request_approval_to' => $user->id, + 'notes' => 'Notes Form', + 'amount' => $amount, + 'activity' => 'Created', + 'details' => array( + [ + 'chart_of_account_id' => $account->id, + 'amount' => $amount, + 'notes' => 'Notes' + ] + ) + ]; + + return CashAdvance::create($data); + } + + public function transformPaymentOrderDetails($paymentOrder) + { + return $paymentOrder->details->transform(function ($detail) { + return [ + 'allocation_id' => $detail->allocation_id, + 'amount' => $detail->amount, + 'chart_of_account_id' => $detail->chart_of_account_id, + 'notes' => $detail->notes + ]; + }); + } + + public function paymentAssertDatabaseHas($response, $data) + { + $this->assertDatabaseHas('payments', [ + 'id' => $response->json('data.id'), + 'payment_type' => "CASH" + ], 'tenant') + ->assertDatabaseHas('forms', [ + 'id' => $response->json('data.form.id') + ], 'tenant'); + + foreach ($data['details'] as $detail) { + $this->assertDatabaseHas('payment_details', $detail, 'tenant'); + } + } + + public function getDataPayment($reference) + { + $paymentAccount = $this->getChartOfAccountCash(); + $this->makePaymentCashIn($paymentAccount, $reference->amount); + $details = []; + if ($reference::$morphName == 'PaymentOrder') { + $details = $this->transformPaymentOrderDetails($reference); + } + + return [ + 'amount' => $reference->amount, + 'payment_type' => $reference->payment_type, + 'date' => date('Y-m-d H:i:s'), + 'increment_group' => date('Ym'), + 'payment_account_id' => $paymentAccount->id, + 'disbursed' => true, + 'paymentable_id' => $reference->paymentable_id, + 'paymentable_type' => $reference->paymentable_type, + 'referenceable_type' => $reference::$morphName, + 'referenceable_id' => $reference->id, + 'details' => $details, + ]; + } + + // Copied from CashAdvanceTest + public function makePaymentCashIn($account, $amount_account) + { + $paymentable = factory(Customer::class)->create(); + $account_detail = ChartOfAccount::where('name', 'OTHER INCOME')->first(); + // s: insert cash in + $data = [ + 'increment_group' => date('Ym'), + 'date' => date('Y-m-d H:i:s'), + 'due_date' => date('Y-m-d H:i:s'), + 'payment_type' => "cash", + 'payment_account_id' => $account->id, + 'paymentable_id' => $paymentable->id, + 'paymentable_name' => $paymentable->name, + 'paymentable_type' => $paymentable::$morphName, + 'disbursed' => false, + 'notes' => null, + 'amount' => $amount_account, + 'details' => array( + [ + 'chart_of_account_id' => $account_detail->id, + 'amount' => $amount_account, + 'allocation_id' => null, + 'allocation_name' => null, + 'notes' => "Kas" + ] + ) + ]; + + Payment::create($data); + // e: insert cash in + } +} diff --git a/tests/Feature/Http/Finance/Cash/CashOutTest.php b/tests/Feature/Http/Finance/Cash/CashOutTest.php new file mode 100644 index 000000000..14025833c --- /dev/null +++ b/tests/Feature/Http/Finance/Cash/CashOutTest.php @@ -0,0 +1,108 @@ +success_cash_out_from_payment_order_with_cash_advance_and_account(); + + $response = $this->json('GET', self::$path . '/payments?join=form,payment_account,details,account,allocation&sort_by=-form.date&fields=payment.*&filter_form=notArchived%3Bnull&filter_like=%7B%7D&filter_equal=%7B%22payment.payment_type%22:%22cash%22%7D&filter_date_min=%7B%22form.date%22:%22' . date('Y-m-01') . '+00:00:00%22%7D&filter_date_max=%7B%22form.date%22:%22' . date('Y-m-d') . '+23:59:59%22%7D&limit=10&includes=form%3Bdetails.chartOfAccount%3Bdetails.allocation%3Bpaymentable&page=1', $this->headers); + $response->assertStatus(200); + } + + // Test success get a cash out + /** @test */ + public function success_get_a_cash_out() + { + $this->success_cash_out_from_payment_order_without_cash_advance(); + $payment = Payment::orderBy('id', 'desc')->first(); + $data = [ + 'includes' => 'form.branch;paymentAccount;details.chartOfAccount;details.allocation' + ]; + $response = $this->json('GET', self::$path . '/payments/' . $payment->id, $data, $this->headers); + $response->assertStatus(200); + } + + // Test create payment order for reference cash out + /** @test */ + public function success_create_payment_order() + { + $data = $this->createPaymentOrder(true); + $response = $this->json('POST', self::$path . '/payment-orders', $data, $this->headers); + $response->assertStatus(201); + + $this->assertDatabaseHas('payment_orders', [ + 'id' => $response->json('data.id') + ], 'tenant') + ->assertDatabaseHas('forms', [ + 'id' => $response->json('data.form.id') + ], 'tenant'); + + foreach ($data['details'] as $detail) { + unset($detail['chart_of_account_name']); + $this->assertDatabaseHas('payment_order_details', $detail, 'tenant'); + } + } + + // Test cash out from payment order without cash advance + /** @test */ + public function success_cash_out_from_payment_order_without_cash_advance() + { + $paymentOrder = $this->createPaymentOrder(); + $data = $this->getDataPayment($paymentOrder); + + $response = $this->json('POST', self::$path . '/payments', $data, $this->headers); + $response->assertStatus(201); + + $this->paymentAssertDatabaseHas($response, $data); + } + + // Test cash out from payment order with cash advance and account + /** @test */ + public function success_cash_out_from_payment_order_with_cash_advance_and_account() + { + $paymentOrder = $this->createPaymentOrder(); + $data = $this->getDataPayment($paymentOrder); + $cashAdvance = $this->createCashAdvance($paymentOrder->amount - 10_000); + + $data['cash_advance'] = [ + 'id' => $cashAdvance->id, + 'close' => false + ]; + + $response = $this->json('POST', self::$path . '/payments', $data, $this->headers); + $response->assertStatus(201); + + $this->paymentAssertDatabaseHas($response, $data); + } + + // Test cash out from payment order with cash advance only + /** @test */ + public function success_cash_out_from_payment_order_with_cash_advance_only() + { + $paymentOrder = $this->createPaymentOrder(); + $data = $this->getDataPayment($paymentOrder); + $cashAdvance = $this->createCashAdvance($paymentOrder->amount); + + $data['cash_advance'] = [ + 'id' => $cashAdvance->id, + 'close' => false + ]; + + $response = $this->json('POST', self::$path . '/payments', $data, $this->headers); + $response->assertStatus(201); + + $this->paymentAssertDatabaseHas($response, $data); + } +}