recurring ) { throw new NotAllowedException( __( 'This transaction is not recurring.' ) ); } if ( (bool) $transaction->active === false ) { return [ 'status' => 'info', 'message' => __( 'An unactive transaction cannot be triggered.' ), ]; } $transactionHistory = $this->recordTransactionHistory( $transaction ); return [ 'status' => 'success', 'message' => __( 'The recurring transaction has been triggered.' ), 'data' => compact( 'transaction', 'transactionHistory' ), ]; } public function reflectTransactionFromRule( TransactionHistory $transactionHistory, ?TransactionActionRule $rule ) { if ( $transactionHistory->is_reflection ) { throw new NotAllowedException( __( 'This transaction history is already a reflection.' ) ); } if ( $transactionHistory->type === Transaction::TYPE_INDIRECT && ! $rule instanceof TransactionActionRule ) { throw new NotAllowedException( __( 'To reflect an indirect transaction, a transaction action rule must be provided.' ) ); } $accounts = config( 'accounting' )[ 'accounts' ]; $subAccount = TransactionAccount::find( $transactionHistory->transaction_account_id ); if ( $subAccount instanceof TransactionAccount ) { /** * If the transaction history is not attached * to not transaction created manually, it's an indirect transcation * and should therefore rely on the rule to determine the account */ if ( $transactionHistory->transaction === null ) { $counterAccount = TransactionAccount::find( $rule->offset_account_id ); $operation = $accounts[ $counterAccount->category_identifier ][ $rule->do ]; } elseif ( $transactionHistory->transaction instanceof Transaction && in_array( $transactionHistory->transaction->type, [ Transaction::TYPE_DIRECT, Transaction::TYPE_ENTITY, Transaction::TYPE_RECURRING, Transaction::TYPE_SCHEDULED, ] ) ) { $operation = $transactionHistory->operation === 'debit' ? 'credit' : 'debit'; $counterAccount = TransactionAccount::find( ns()->option->get( 'ns_accounting_default_paid_expense_offset_account' ) ); } else { throw new NotAllowedException( __( 'Invalid transaction history provided for reflection.' ) ); } // This will display an error if the offset account is not set. if ( ! $counterAccount instanceof TransactionAccount ) { throw new NotFoundException( __( 'The offset account is not found.' ) ); } if ( $counterAccount instanceof TransactionAccount ) { $counterTransaction = new TransactionHistory; $counterTransaction->value = $transactionHistory->value; $counterTransaction->transaction_id = $transactionHistory->transaction_id; $counterTransaction->operation = $operation; $counterTransaction->author_id = $transactionHistory->author_id; $counterTransaction->name = $transactionHistory->name; $counterTransaction->status = TransactionHistory::STATUS_ACTIVE; $counterTransaction->trigger_date = ns()->date->toDateTimeString(); $counterTransaction->type = $transactionHistory->type; $counterTransaction->procurement_id = $transactionHistory->procurement_id; $counterTransaction->order_id = $transactionHistory->order_id; $counterTransaction->order_refund_id = $transactionHistory->order_refund_id; $counterTransaction->order_product_id = $transactionHistory->order_product_id; $counterTransaction->order_refund_product_id = $transactionHistory->order_refund_product_id; $counterTransaction->register_history_id = $transactionHistory->register_history_id; $counterTransaction->customer_account_history_id = $transactionHistory->customer_account_history_id; $counterTransaction->transaction_account_id = $counterAccount->id; $counterTransaction->is_reflection = true; $counterTransaction->reflection_source_id = $transactionHistory->id; $counterTransaction->save(); } } else { ns()->notification->create( title: __( 'Accounting Misconfiguration' ), identifier: 'accounting-reflection-transaction-misconfiguration', url: ns()->route( 'ns.dashboard.transactions-rules' ), description: __( 'Unable to reflect the transaction as the account type is not found.' ) )->dispatchForPermissions( [ 'nexopos.create.transactions-account' ] ); } } /** * Get the transaction account by code */ public function deleteTransactionReflection( TransactionHistory $transactionHistory ) { $reflection = TransactionHistory::where( 'reflection_source_id', $transactionHistory->id )->first(); if ( $reflection instanceof TransactionHistory ) { $reflection->delete(); /** * We'll instruct NexoPOS to perform * a backend jobs to update the report. */ ShouldRefreshReportEvent::dispatch( $transactionHistory->created_at ); return [ 'status' => 'success', 'message' => __( 'The reflection has been deleted.' ), ]; } return [ 'status' => 'info', 'message' => __( 'No reflection found.' ), ]; } public function create( $fields ) { $transaction = new Transaction; foreach ( $fields as $field => $value ) { $transaction->$field = $value; } $transaction->author_id = Auth::id(); $transaction->save(); event( new TransactionAfterCreatedEvent( $transaction, request()->all() ) ); return [ 'status' => 'success', 'message' => __( 'The transaction has been successfully saved.' ), 'data' => compact( 'transaction' ), ]; } public function edit( $id, $fields ) { $transaction = $this->get( $id ); if ( $transaction instanceof Transaction ) { foreach ( $fields as $field => $value ) { $transaction->$field = $value; } $transaction->author_id = Auth::id(); $transaction->save(); event( new TransactionAfterUpdatedEvent( $transaction, request()->all() ) ); return [ 'status' => 'success', 'message' => __( 'The transaction has been successfully updated.' ), 'data' => compact( 'transaction' ), ]; } throw new NotFoundException( __( 'Unable to find the transaction using the provided identifier.' ) ); } /** * get a specific transaction using * the provided id * * @throws NotFoundException */ public function get( ?int $id = null ): Collection|Transaction { if ( $id === null ) { return Transaction::get(); } $transaction = Transaction::find( $id ); if ( ! $transaction instanceof Transaction ) { throw new NotFoundException( __( 'Unable to find the requested transaction using the provided id.' ) ); } return $transaction; } /** * Delete an transaction using the * provided id * * @param int transaction id * @return array */ public function deleteTransaction( Transaction $transaction ) { $transaction->history()->delete(); $transaction->delete(); return [ 'status' => 'success', 'message' => __( 'The transaction has been correctly deleted.' ), ]; } /** * Retreive a specific account type * or all account type */ public function getTransactionAccountByID( ?int $id = null ) { if ( $id !== null ) { $account = TransactionAccount::find( $id ); if ( ! $account instanceof TransactionAccount ) { throw new NotFoundException( __( 'Unable to find the requested account type using the provided id.' ) ); } return $account; } return TransactionAccount::get(); } /** * Get all transaction accounts * * @return Collection */ public function getSubAccounts() { return TransactionAccount::whereNotNull( 'sub_category_id' )->get(); } public function getActions() { return [ TransactionActionRule::RULE_PROCUREMENT_PAID => __( 'Procurement Paid' ), TransactionActionRule::RULE_PROCUREMENT_UNPAID => __( 'Procurement Unpaid' ), TransactionActionRule::RULE_PROCUREMENT_FROM_UNPAID_TO_PAID => __( 'Paid Procurement From Unpaid' ), TransactionActionRule::RULE_ORDER_PAID => __( 'Order Paid' ), TransactionActionRule::RULE_ORDER_UNPAID => __( 'Order Unpaid' ), TransactionActionRule::RULE_ORDER_REFUNDED => __( 'Order Refund' ), TransactionActionRule::RULE_ORDER_PARTIALLY_PAID => __( 'Order Partially Paid' ), TransactionActionRule::RULE_ORDER_PARTIALLY_REFUNDED => __( 'Order Partially Refunded' ), TransactionActionRule::RULE_ORDER_FROM_UNPAID_TO_PAID => __( 'Order From Unpaid To Paid' ), TransactionActionRule::RULE_ORDER_PAID_VOIDED => __( 'Paid Order Voided' ), TransactionActionRule::RULE_ORDER_UNPAID_VOIDED => __( 'Unpaid Order Voided' ), TransactionActionRule::RULE_ORDER_COGS => __( 'Order COGS' ), TransactionActionRule::RULE_PRODUCT_DAMAGED => __( 'Product Damaged' ), TransactionActionRule::RULE_PRODUCT_RETURNED => __( 'Product Returned' ), ]; } public function getActionLabel( $action ) { $actions = $this->getActions(); return $actions[ $action ] ?? __( 'Unknown' ); } public function getRules(): array { $catalog = app( AccountingEventCatalog::class ); return [ 'groups' => TransactionActionRule::query() ->where( 'active', true ) ->with( 'lines.account' ) ->orderBy( 'on' ) ->get(), 'events' => $catalog->all(), ]; } /** * Delete specific account type * * @param bool $force * @return array */ public function deleteTransactionAccount( TransactionAccount $account, $force = true ) { if ( $account->transactions->count() > 0 && $force === false ) { throw new NotAllowedException( __( 'You cannot delete an account type that has transaction bound.' ) ); } /** * if there is not transaction, it * won't be looped */ $account->transactions->map( function ( $transaction ) { $this->deleteTransaction( $transaction ); } ); $account->delete(); return [ 'status' => 'success', 'message' => __( 'The account type has been deleted.' ), ]; } /** * Get a specific transaction * account using the provided ID * * @throws NotFoundException */ public function getTransaction( int $id ): TransactionAccount { $accountType = TransactionAccount::with( 'transactions' )->find( $id ); if ( ! $accountType instanceof TransactionAccount ) { throw new NotFoundException( __( 'Unable to find the transaction account using the provided ID.' ) ); } return $accountType; } /** * Creates an accounting account */ public function createAccount( array $fields ): array { $accounting = config( 'accounting' ); if ( ! isset( $accounting[ 'accounts' ][ $fields[ 'category_identifier' ] ] ) ) { throw new NotAllowedException( __( 'The account type is not found.' ) ); } /** * if the account is not provided, we'll try to create * a custom numbering using the main account number including it's * name and the sub account name. */ $fields[ 'account' ] = ! isset( $fields[ 'account' ] ) ? $this->getAccountNumber( $fields[ 'category_identifier' ], $fields[ 'name' ] ) : $fields[ 'account' ]; /** * We want to prevent creating the same account * if the account code is similar. This is mostly * done for testing purposes. */ $accountCode = explode( '-', $fields[ 'account' ] ); unset( $accountCode[0] ); $accountCode = implode( '-', $accountCode ); $account = TransactionAccount::where( 'account', 'like', '%' . $accountCode . '%' )->firstOrNew(); foreach ( $fields as $field => $value ) { $account->$field = $value; } $account->author_id = ns()->getValidAuthor(); $account->save(); return [ 'status' => 'success', 'message' => __( 'The account has been created.' ), 'data' => compact( 'account' ), ]; } /** * Update specified expense * account using a provided ID * * @todo not covered */ public function editTransactionAccount( TransactionAccount $account, array $fields ): array { foreach ( $fields as $field => $value ) { $account->$field = $value; } $account->author_id = Auth::id(); $account->save(); return [ 'status' => 'success', 'message' => __( 'The transaction account has been updated.' ), 'data' => compact( 'account' ), ]; } /** * Will delete all cash flow * related to the specific order * * @return void */ public function deleteOrderTransactionsHistory( $order ) { TransactionHistory::where( 'order_id', $order->id )->delete(); } /** * Will trigger all transactions history * * @return array */ public function triggerTransactionHistory( TransactionHistory $transactionHistory ) { if ( $transactionHistory->status === TransactionHistory::STATUS_PENDING ) { $transactionHistory->status = TransactionHistory::STATUS_ACTIVE; $transactionHistory->save(); return [ 'status' => 'success', 'message' => __( 'The transaction history has been triggered.' ), 'data' => compact( 'transactionHistory' ), ]; } return [ 'status' => 'error', 'message' => __( 'The transaction history has already been triggered.' ), ]; } /** * Will trigger for not recurring transaction * * @throws NotAllowedException */ public function triggerTransaction( Transaction $transaction ): array { if ( ! in_array( $transaction->type, [ Transaction::TYPE_DIRECT, Transaction::TYPE_ENTITY, Transaction::TYPE_SCHEDULED, Transaction::TYPE_INDIRECT, ] ) ) { throw new NotAllowedException( __( 'This transaction type can\'t be triggered.' ) ); } $histories = $this->recordTransactionHistory( $transaction ); /** * a non recurring transaction * once triggered should be disabled to * prevent further execution on modification. */ $transaction->active = false; $transaction->save(); return [ 'status' => 'success', 'message' => __( 'The transaction has been successfully triggered.' ), 'data' => compact( 'transaction', 'histories' ), ]; } /** * Will provide all transactions linked to a specific account * * @param int $id the account id * @return Collection */ public function getAccountTransactions( $id ) { $accountType = $this->getTransaction( $id ); return $accountType->transactions; } /** * Will prepare a transaction history based on a transaction reference * * @return array */ public function prepareTransactionHistoryRecord( Transaction $transaction ) { $history = $this->iniTransactionHistory( $transaction ); $history->status = TransactionHistory::STATUS_PENDING; $history->trigger_date = $transaction->scheduled_date; $history->save(); return [ 'status' => 'success', 'message' => __( 'The transaction history is created.' ), ]; } /** * Will prepare a transaction history based on a transaction reference * * @return TransactionHistory * * @throws NotFoundException */ public function iniTransactionHistory( Transaction $transaction ) { $mainIdentifier = $transaction->account->category_identifier; $mainAccount = config( 'accounting.accounts' )[ $mainIdentifier ]; if ( ! $mainAccount ) { throw new NotFoundException( sprintf( __( 'The account type %s is not found.' ), $mainIdentifier ) ); } $history = new TransactionHistory; $history->value = $transaction->value; $history->transaction_id = $transaction->id; $history->operation = $mainAccount[ 'increase' ]; // if the operation is not defined, by default is a "debit" $history->author_id = $transaction->author_id; $history->name = $transaction->name; $history->status = TransactionHistory::STATUS_ACTIVE; $history->trigger_date = ns()->date->toDateTimeString(); $history->type = $transaction->type; $history->procurement_id = $transaction->procurement_id ?? 0; // if the cash flow is created from a procurement $history->order_id = $transaction->order_id ?? 0; // if the cash flow is created from a refund $history->order_refund_id = $transaction->order_refund_id ?? 0; // if the cash flow is created from a refund $history->order_product_id = $transaction->order_product_id ?? 0; // if the cash flow is created from a refund $history->order_refund_product_id = $transaction->order_refund_product_id ?? 0; // if the cash flow is created from a refund $history->register_history_id = $transaction->register_history_id ?? 0; // if the cash flow is created from a register transaction $history->customer_account_history_id = $transaction->customer_account_history_id ?? 0; // if the cash flow is created from a customer payment. $history->transaction_account_id = $transaction->account->id; return $history; } /** * Will record a transaction history based on a transaction reference * * @param Transaction $transaction * @return Collection | bool * * @throws ModelNotFoundException */ public function recordTransactionHistory( $transaction ) { if ( ! empty( $transaction->group_id ) ) { return Role::find( $transaction->group_id )->users()->get()->map( function ( $user ) use ( $transaction ) { if ( $transaction->account instanceof TransactionAccount ) { $history = new TransactionHistory; $history->value = $transaction->value; $history->transaction_id = $transaction->id; $history->operation = 'debit'; $history->author_id = $transaction->author_id; $history->trigger_date = ns()->date->toDateTimeString(); $history->type = $transaction->type; $history->status = TransactionHistory::STATUS_ACTIVE; $history->name = str_replace( '{user}', ucwords( $user->username ), $transaction->name ); $history->transaction_account_id = $transaction->account->id; $history->save(); return $history; } return false; } )->filter(); // only return valid history created } else { if ( $transaction->account instanceof TransactionAccount ) { $history = $this->iniTransactionHistory( $transaction ); $history->save(); return collect( [ $history ] ); } else { throw new ModelNotFoundException( sprintf( 'The transaction account is not found.' ) ); } } } /** * Process recorded transactions * and check whether they are supposed to be processed * on the current day. * * @return array of process results. */ public function handleRecurringTransactions( ?Carbon $date = null ) { if ( $date === null ) { $date = $this->dateService->copy(); } $processStatus = Transaction::recurring() ->active() ->get() ->map( function ( $transaction ) use ( $date ) { switch ( $transaction->occurrence ) { case 'month_starts': $transactionScheduledDate = $date->copy()->startOfMonth(); break; case 'month_mid': $transactionScheduledDate = $date->copy()->startOfMonth()->addDays( 14 ); break; case 'month_ends': $transactionScheduledDate = $date->copy()->endOfMonth(); break; case 'x_before_month_ends': $transactionScheduledDate = $date->copy()->endOfMonth()->subDays( $transaction->occurrence_value ); break; case 'x_after_month_starts': $transactionScheduledDate = $date->copy()->startOfMonth()->addDays( $transaction->occurrence_value ); break; case 'on_specific_day': $transactionScheduledDate = $date->copy(); $transactionScheduledDate->day = $transaction->occurrence_value; break; case 'every_x_minutes': $transactionScheduledDate = $date->copy(); $transactionScheduledDate->day = $transaction->occurrence_value; break; case 'every_x_hours': $transactionScheduledDate = $date->copy(); $transactionScheduledDate->hour = now()->hour; break; case 'every_x_days': $transactionScheduledDate = $date->copy(); $transactionScheduledDate->minute = now()->minute; break; } if ( isset( $transactionScheduledDate ) && $transactionScheduledDate instanceof Carbon ) { /** * Checks if the recurring transactions about to be saved has been * already issued on the occuring day. */ if ( $date->isSameDay( $transactionScheduledDate ) ) { if ( ! $this->hadTransactionHistory( $transactionScheduledDate, $transaction ) ) { $histories = $this->recordTransactionHistory( $transaction ); return [ 'status' => 'success', 'data' => compact( 'transaction', 'histories' ), 'message' => sprintf( __( 'The transaction "%s" has been processed on day "%s".' ), $transaction->name, $date->toDateTimeString() ), ]; } return [ 'status' => 'error', 'message' => sprintf( __( 'The transaction "%s" has already been processed.' ), $transaction->name ), ]; } } return [ 'status' => 'error', 'message' => sprintf( __( 'The transactions "%s" hasn\'t been processed, as it\'s out of date.' ), $transaction->name ), ]; } ); $successFulProcesses = collect( $processStatus )->filter( fn( $process ) => $process[ 'status' ] === 'success' ); return [ 'status' => 'success', 'data' => $processStatus->toArray(), 'message' => $successFulProcesses->count() === $processStatus->count() ? __( 'The process has been correctly executed and all transactions has been processed.' ) : sprintf( __( 'The process has been executed with some failures. %s/%s process(es) succeeded.' ), $successFulProcesses->count(), $processStatus->count() ), ]; } /** * Check if an transaction has been executed during a day. * To prevent many recurring transactions to trigger multiple times * during a day. */ public function hadTransactionHistory( $date, Transaction $transaction ) { $history = TransactionHistory::where( 'transaction_id', $transaction->id ) ->where( 'created_at', '>=', $date->startOfDay()->toDateTimeString() ) ->where( 'created_at', '<=', $date->endOfDay()->toDateTimeString() ) ->get(); return ! $history->isEmpty(); } /** * Will record a transaction resulting from a paid procurement * * @return void */ public function handleProcurementTransaction( Procurement $procurement ) { $transactionHistory = new TransactionHistory; $accounts = config( 'accounting' )[ 'accounts' ]; if ( $procurement->payment_status === Procurement::PAYMENT_UNPAID ) { $rule = TransactionActionRule::where( 'on', TransactionActionRule::RULE_PROCUREMENT_UNPAID )->first(); $transactionHistory->name = sprintf( __( 'Unpaid Procurement: %s' ), $procurement->name ); } elseif ( $procurement->payment_status === Procurement::PAYMENT_PAID ) { /** * if the transaction has some previous records * this probably means the procurement was initially stored as unpaid. * therefore we should use the from unpaid to paid rule. */ $previousRecordsCount = TransactionHistory::where( 'procurement_id', $procurement->id )->count(); if ( $previousRecordsCount > 0 ) { $rule = TransactionActionRule::where( 'on', TransactionActionRule::RULE_PROCUREMENT_FROM_UNPAID_TO_PAID )->first(); $transactionHistory->name = sprintf( __( 'Paid Procurement: %s' ), $procurement->name ); } else { $rule = TransactionActionRule::where( 'on', TransactionActionRule::RULE_PROCUREMENT_PAID )->first(); $transactionHistory->name = sprintf( __( 'Paid Procurement: %s' ), $procurement->name ); } } else { throw new NotAllowedException( __( 'The procurement payment status is not supported.' ) ); } /** * We'll check if the account and the offset account * are found before proceeding. */ if ( ! $rule instanceof TransactionActionRule ) { if ( $procurement->payment_status === Procurement::PAYMENT_PAID ) { return ns()->notification->create( title: __( 'Accounting Misconfiguration' ), identifier: 'accounting-procurement-misconfiguration', url: ns()->route( 'ns.dashboard.transactions-rules' ), description: __( 'Unable to record accounting transactions for paid procurement. Until the accounts rules are set, records are skipped.' ) )->dispatchForPermissions( [ 'nexopos.create.transactions-account' ] ); } else { return ns()->notification->create( title: __( 'Accounting Misconfiguration' ), identifier: 'accounting-procurement-misconfiguration', url: ns()->route( 'ns.dashboard.transactions-rules' ), description: __( 'Unable to record accounting transactions for unpaid procurement. Until the accounts rules are set, records are skipped.' ) )->dispatchForPermissions( [ 'nexopos.create.transactions-account' ] ); } } $account = TransactionAccount::find( $rule->account_id ); $offset = TransactionAccount::find( $rule->offset_account_id ); if ( ! $account instanceof TransactionAccount ) { return ns()->notification->create( title: __( 'Accounting Misconfiguration' ), identifier: 'accounting-procurement-misconfiguration', url: ns()->route( 'ns.dashboard.transactions-rules' ), description: sprintf( __( 'Unable to record accounting transactions as the account set to the rule assigned to the action "%s" can\'t be found.' ), $this->getActionLabel( $rule->on ) ) )->dispatchForPermissions( [ 'nexopos.create.transactions-account' ] ); } if ( ! $offset instanceof TransactionAccount ) { return ns()->notification->create( title: __( 'Accounting Misconfiguration' ), identifier: 'accounting-procurement-misconfiguration', url: ns()->route( 'ns.dashboard.transactions-rules' ), description: sprintf( __( 'Unable to record accounting transactions as the offset account set to the rule assigned to the action "%s" can\'t be found.' ), $this->getActionLabel( $rule->on ) ) )->dispatchForPermissions( [ 'nexopos.create.transactions-account' ] ); } $operation = $accounts[ $account->category_identifier ][ $rule->action ]; $transactionHistory->value = $procurement->cost; $transactionHistory->author_id = $procurement->author_id; $transactionHistory->transaction_account_id = $account->id; $transactionHistory->operation = $operation; $transactionHistory->type = Transaction::TYPE_DIRECT; $transactionHistory->trigger_date = $procurement->created_at; $transactionHistory->status = TransactionHistory::STATUS_ACTIVE; $transactionHistory->procurement_id = $procurement->id; $transactionHistory->rule_id = $rule->id; $transactionHistory->save(); } /** * Will record a transaction resulting from a paid procurement * * @return void */ public function handleUnpaidToPaidSaleTransaction( Order $order ) { $rule = TransactionActionRule::where( 'on', TransactionActionRule::RULE_ORDER_FROM_UNPAID_TO_PAID )->first(); if ( ! $rule instanceof TransactionActionRule ) { return ns()->notification->create( title: __( 'Accounting Rule Misconfiguration' ), identifier: 'accounting-unpaid-to-paid-order-misconfiguration', url: ns()->route( 'ns.dashboard.transactions-rules' ), description: sprintf( __( 'Unable to record accounting transactions for the order %s which was initially unpaid and then paid. No rule was set for this.' ), $order->code ) )->dispatchForPermissions( [ 'nexopos.create.transactions-account' ] ); } $accounts = config( 'accounting' )[ 'accounts' ]; $account = TransactionAccount::find( $rule->account_id ); if ( ! $account instanceof TransactionAccount ) { return ns()->notification->create( title: __( 'Accounting Rule Misconfiguration' ), identifier: 'accounting-unpaid-to-paid-order-misconfiguration', url: ns()->route( 'ns.dashboard.transactions-rules' ), description: sprintf( __( 'Unable to record accounting transactions for the order %s which was initially unpaid and then paid. The account set to the rule assigned to the action "%s" can\'t be found.' ), $order->code, $this->getActionLabel( $rule->on ) ) )->dispatchForPermissions( [ 'nexopos.create.transactions-account' ] ); } $operation = $accounts[ $account->category_identifier ][ $rule->action ]; $this->createOrderTransactionHistory( order: $order, operation: $operation, value: 'total', name: sprintf( __( 'Order: %s' ), $order->code ), account: $account, rule: $rule ); } public function handleUnpaidToVoidSaleTransaction( Order $order ) { $rule = TransactionActionRule::where( 'on', TransactionActionRule::RULE_ORDER_UNPAID_VOIDED )->first(); if ( ! $rule instanceof TransactionActionRule ) { return ns()->notification->create( title: __( 'Accounting Rule Misconfiguration' ), identifier: 'accounting-unpaid-to-void-order-misconfiguration', url: ns()->route( 'ns.dashboard.transactions-rules' ), description: sprintf( __( 'Unable to record accounting transactions for the order %s which was initially unpaid and then voided. No rule was set for this.' ), $order->code ) )->dispatchForPermissions( [ 'nexopos.create.transactions-account' ] ); } $accounts = config( 'accounting' )[ 'accounts' ]; $account = TransactionAccount::find( $rule->account_id ); if ( ! $account instanceof TransactionAccount ) { return ns()->notification->create( title: __( 'Accounting Rule Misconfiguration' ), identifier: 'accounting-unpaid-to-void-order-misconfiguration', url: ns()->route( 'ns.dashboard.transactions-rules' ), description: sprintf( __( 'Unable to record accounting transactions for the order %s which was initially unpaid and then voided. The account set to the rule assigned to the action "%s" can\'t be found.' ), $order->code, $this->getActionLabel( $rule->on ) ) )->dispatchForPermissions( [ 'nexopos.create.transactions-account' ] ); } $operation = $accounts[ $account->category_identifier ][ $rule->action ]; $transactionHistory = $this->createOrderTransactionHistory( order: $order, operation: $operation, value: 'total', account: $account, name: sprintf( __( 'Void Order: %s' ), $order->code ), rule: $rule ); return JsonResponse::success( message: __( 'The transaction has been recorded.' ), data: compact( 'transactionHistory' ) ); } /** * Will record a transaction resulting from a paid procurement * * @return void */ public function handlePaidToVoidSaleTransaction( Order $order ) { $rule = TransactionActionRule::where( 'on', TransactionActionRule::RULE_ORDER_PAID_VOIDED )->first(); if ( ! $rule instanceof TransactionActionRule ) { return ns()->notification->create( title: __( 'Accounting Rule Misconfiguration' ), identifier: 'accounting-paid-to-void-order-misconfiguration', url: ns()->route( 'ns.dashboard.transactions-rules' ), description: sprintf( __( 'Unable to record accounting transactions for the order %s which was initially paid and then voided. No rule was set for this.' ), $order->code ) )->dispatchForPermissions( [ 'nexopos.create.transactions-account' ] ); } $accounts = config( 'accounting' )[ 'accounts' ]; $account = TransactionAccount::find( $rule->account_id ); if ( ! $account instanceof TransactionAccount ) { return ns()->notification->create( title: __( 'Accounting Rule Misconfiguration' ), identifier: 'accounting-paid-to-void-order-misconfiguration', url: ns()->route( 'ns.dashboard.transactions-rules' ), description: sprintf( __( 'Unable to record accounting transactions for the order %s which was initially paid and then voided. The account set to the rule assigned to the action "%s" can\'t be found.' ), $order->code, $this->getActionLabel( $rule->on ) ) )->dispatchForPermissions( [ 'nexopos.create.transactions-account' ] ); } $operation = $accounts[ $account->category_identifier ][ $rule->action ]; $transactionHistory = $this->createOrderTransactionHistory( order: $order, operation: $operation, value: 'total', account: $account, name: sprintf( __( 'Void Order: %s' ), $order->code ), rule: $rule ); return [ 'status' => 'success', 'message' => __( 'The transaction has been recorded.' ), 'data' => compact( 'transactionHistory' ), ]; } /** * Will record a transaction using an order, rule and other informations * * @param string $operation * @param string $name * @param TransactionAccount $account * @param TransactionActionRule $rule * @param string $value * @return TransactionHistory */ private function createOrderTransactionHistory( Order $order, $operation, $name, $account, $rule, $value ) { $transactionHistory = new TransactionHistory; $transactionHistory->name = $name; $transactionHistory->value = $order->$value; $transactionHistory->author_id = $order->author_id; $transactionHistory->transaction_account_id = $account->id; $transactionHistory->operation = $operation; $transactionHistory->type = Transaction::TYPE_INDIRECT; $transactionHistory->trigger_date = $order->created_at; $transactionHistory->status = TransactionHistory::STATUS_ACTIVE; $transactionHistory->order_id = $order->id; $transactionHistory->rule_id = $rule->id; $transactionHistory->save(); return $transactionHistory; } /** * Will record a transaction for any created order * * @return array */ public function handleSaleTransaction( Order $order ) { $ruleOn = match ( $order->payment_status ) { Order::PAYMENT_PAID => TransactionActionRule::RULE_ORDER_PAID, Order::PAYMENT_UNPAID => TransactionActionRule::RULE_ORDER_UNPAID, Order::PAYMENT_REFUNDED => TransactionActionRule::RULE_ORDER_REFUNDED, Order::PAYMENT_PARTIALLY => TransactionActionRule::RULE_ORDER_PARTIALLY_PAID, }; $accounts = config( 'accounting' )[ 'accounts' ]; $rule = TransactionActionRule::where( 'on', $ruleOn )->first(); if ( ! $rule instanceof TransactionActionRule ) { ns()->notification->create( title: __( 'Accounting Rule Misconfiguration' ), identifier: 'accounting-sale-misconfiguration', url: ns()->route( 'ns.dashboard.transactions-rules' ), description: sprintf( __( 'Unable to record accounting transactions for the order %s. No rule was set for this.' ), $order->code ) )->dispatchForPermissions( [ 'nexopos.create.transactions-account' ] ); return [ 'status' => 'error', 'message' => __( 'The transaction has been skipped.' ), ]; } $account = TransactionAccount::find( $rule->account_id ); $offset = TransactionAccount::find( $rule->offset_account_id ); if ( ! $account instanceof TransactionAccount ) { return ns()->notification->create( title: __( 'Accounting Rule Misconfiguration' ), identifier: 'accounting-sale-misconfiguration', url: ns()->route( 'ns.dashboard.transactions-rules' ), description: sprintf( __( 'Unable to record accounting transactions for the order %s. The accounts set to the rule assigned to the action "%s" can\'t be found.' ), $order->code, $this->getActionLabel( $rule->on ) ) )->dispatchForPermissions( [ 'nexopos.create.transactions-account' ] ); return [ 'status' => 'error', 'message' => __( 'The transaction has been skipped.' ), ]; } $operation = $accounts[ $account->category_identifier ][ $rule->action ]; $transactionHistory = $this->createOrderTransactionHistory( order: $order, operation: $operation, value: 'total', name: sprintf( __( 'Order: %s' ), $order->code ), account: $account, rule: $rule ); return [ 'status' => 'success', 'message' => __( 'The transaction has been recorded.' ), 'data' => compact( 'transactionHistory' ), ]; } /** * Will record COGS transaction for paid order * * @return array */ public function handleCogsFromSale( Order $order ) { $rule = TransactionActionRule::where( 'on', TransactionActionRule::RULE_ORDER_COGS )->first(); $accounts = config( 'accounting' )[ 'accounts' ]; if ( ! $rule instanceof TransactionActionRule ) { return ns()->notification->create( title: __( 'Accounting Rule Misconfiguration' ), identifier: 'accounting-sale-misconfiguration', url: ns()->route( 'ns.dashboard.transactions-rules' ), description: sprintf( __( 'Unable to record COGS transactions for order %s. Until the accounts rules are set, records are skipped.' ), $order->code ) )->dispatchForPermissions( [ 'nexopos.create.transactions-account' ] ); } /** * We'll only take this into account * if the order is paid. */ if ( $order->payment_status === Order::PAYMENT_PAID ) { $account = TransactionAccount::find( $rule->account_id ); $offset = TransactionAccount::find( $rule->offset_account_id ); if ( ! $account instanceof TransactionAccount || ! $offset instanceof TransactionAccount ) { return ns()->notification->create( title: __( 'Accounting Rule Misconfiguration' ), identifier: 'accounting-sale-misconfiguration', url: ns()->route( 'ns.dashboard.transactions-rules' ), description: sprintf( __( 'Unable to record COGS transactions for order %s. Make sure either the account or the offset account are set and assigned to the rule %s.' ), $order->code, $this->getActionLabel( $rule->on ) ) )->dispatchForPermissions( [ 'nexopos.create.transactions-account' ] ); } $operation = $accounts[ $account->category_identifier ][ $rule->action ]; $transactionHistory = $this->createOrderTransactionHistory( order: $order, operation: $operation, value: 'total_cogs', name: sprintf( __( 'COGS: %s' ), $order->code ), account: $account, rule: $rule ); return [ 'status' => 'success', 'message' => __( 'The COGS transaction has been recorded.' ), 'data' => compact( 'transactionHistory' ), ]; } } /** * Provides the configuration for the transaction * * @return array */ public function getConfigurations( Transaction $transaction ) { $recurringFields = new ReccurringTransactionFields( $transaction ); $directFields = new DirectTransactionFields( $transaction ); $entityFields = new EntityTransactionFields( $transaction ); $scheduledFields = new ScheduledTransactionFields( $transaction ); $asyncTransactions = []; $warningMessage = false; /** * Those features can only be enabled * if the jobs are configured correctly. */ if ( ns()->canPerformAsynchronousOperations() ) { $asyncTransactions = [ [ 'identifier' => ReccurringTransactionFields::getIdentifier(), 'label' => __( 'Recurring Expense' ), 'icon' => asset( 'images/recurring.png' ), 'fields' => $recurringFields->get(), ], [ 'identifier' => EntityTransactionFields::getIdentifier(), 'label' => __( 'Entity Expense' ), 'icon' => asset( 'images/salary.png' ), 'fields' => $entityFields->get(), ], [ 'identifier' => ScheduledTransactionFields::getIdentifier(), 'label' => __( 'Scheduled Expense' ), 'icon' => asset( 'images/schedule.png' ), 'fields' => $scheduledFields->get(), ], ]; } else { $warningMessage = sprintf( __( 'Some expense type are disabled as NexoPOS is not able to perform asynchronous requests.' ), 'https://my.nexopos.com/en/documentation/troubleshooting/workers-or-async-requests-disabled' ); } $configurations = Hook::filter( 'ns-transactions-configurations', [ [ 'identifier' => DirectTransactionFields::getIdentifier(), 'label' => __( 'Direct Expense' ), 'icon' => asset( 'images/budget.png' ), 'fields' => $directFields->get(), ], ...$asyncTransactions, ] ); $recurrence = Hook::filter( 'ns-transactions-recurrence', [ [ 'type' => 'select', 'label' => __( 'Condition' ), 'name' => 'occurrence', 'value' => $transaction->occurrence ?? '', 'options' => Hook::filter( 'ns-transactions-recurrence-options', Helper::kvToJsOptions( [ Transaction::OCCURRENCE_START_OF_MONTH => __( 'First Day Of Month' ), Transaction::OCCURRENCE_END_OF_MONTH => __( 'Last Day Of Month' ), Transaction::OCCURRENCE_MIDDLE_OF_MONTH => __( 'Month middle Of Month' ), Transaction::OCCURRENCE_X_AFTER_MONTH_STARTS => __( '{day} after month starts' ), Transaction::OCCURRENCE_X_BEFORE_MONTH_ENDS => __( '{day} before month ends' ), Transaction::OCCURRENCE_SPECIFIC_DAY => __( 'Every {day} of the month' ), Transaction::OCCURRENCE_EVERY_X_MINUTES => __( 'Every {minutes}' ), Transaction::OCCURRENCE_EVERY_X_HOURS => __( 'Every {hours}' ), Transaction::OCCURRENCE_EVERY_X_DAYS => __( 'Every {days}' ), ] ) ), ], [ 'type' => 'number', 'label' => __( 'Days' ), 'name' => 'occurrence_value', 'value' => $transaction->occurrence_value ?? 0, 'shows' => [ 'occurrence' => [ Transaction::OCCURRENCE_X_AFTER_MONTH_STARTS, Transaction::OCCURRENCE_X_BEFORE_MONTH_ENDS, Transaction::OCCURRENCE_SPECIFIC_DAY, Transaction::OCCURRENCE_EVERY_X_MINUTES, Transaction::OCCURRENCE_EVERY_X_HOURS, Transaction::OCCURRENCE_EVERY_X_DAYS, ], ], 'description' => __( 'Make sure set a day that is likely to be executed' ), ], ] ); return compact( 'recurrence', 'configurations', 'warningMessage' ); } /** * Deletes procurement transactions * * @return array */ public function deleteProcurementTransactions( Procurement $procurement ) { $transactionHistories = TransactionHistory::where( 'procurement_id', $procurement->id ) ->where( 'is_reflection', false ) ->get(); foreach ( $transactionHistories as $transactionHistory ) { $transactionHistory->delete(); } return [ 'status' => 'success', 'message' => __( 'The procurement transactions has been deleted.' ), ]; } /** * Clear and create default accounts. * * @return array */ public function createDefaultAccounts() { $this->upgradeAccountingFoundation(); return [ 'status' => 'success', 'message' => __( 'The default accounts have been verified.' ), ]; } /** * Will clear all accounts * * @return array */ public function clearAllAccounts() { TransactionHistory::truncate(); AccountingJournal::truncate(); TransactionActionRuleLine::truncate(); TransactionActionRule::truncate(); Transaction::truncate(); TransactionAccount::truncate(); return [ 'status' => 'success', 'message' => __( 'The accounts configuration was cleared' ), ]; } /** * Returns the account number using an account name and a current name * * @return string */ public function getAccountNumber( string $accountName, string $currentName ) { $accounts = config( 'accounting' )[ 'accounts' ]; $account = $accounts[ $accountName ]; if ( $account ) { $count = TransactionAccount::where( 'category_identifier', $accountName )->count(); return $account[ 'account' ] + ( $count + 1 ) . '-' . Str::slug( $accountName ) . '-' . Str::slug( $currentName ); } throw new NotAllowedException( __( 'Invalid account name' ) ); } /** * Creates all sub accounts * and creates accounting rules. */ public function createAllSubAccounts(): void { $this->upgradeAccountingFoundation(); } /** * Sets transaction rule */ public function setTransactionActionRule( string $on, string $action, int $account_id, string $do, int $offset_account_id, ?TransactionActionRule $transactionActionRule = null ): array { $transactionActionRule = $transactionActionRule instanceof TransactionActionRule ? $transactionActionRule : TransactionActionRule::firstOrNew( [ 'on' => $on ] ); $transactionActionRule->on = $on; $transactionActionRule->action = $action; $transactionActionRule->account_id = $account_id; $transactionActionRule->do = $do; $transactionActionRule->offset_account_id = $offset_account_id; $transactionActionRule->save(); return [ 'status' => 'success', 'message' => __( 'The accounting action has been saved' ), ]; } /** * Saves transactions rule. */ public function saveTransactionRule( array $rule ): array { app( AccountingRuleValidator::class )->validate( $rule ); $transactionRule = DB::transaction( function () use ( $rule ): TransactionActionRule { $transactionRule = TransactionActionRule::query()->find( $rule['id'] ?? 0 ) ?? new TransactionActionRule; $fallback = TransactionAccount::query()->where( 'system_identifier', 'payment_clearing' )->firstOrFail(); $first = $rule['lines'][0]; $second = $rule['lines'][1]; TransactionActionRule::query() ->where( 'on', $rule['on'] ) ->when( $transactionRule->exists, fn( $query ) => $query->whereKeyNot( $transactionRule->id ) ) ->update( [ 'active' => false ] ); $transactionRule->fill( [ 'on' => $rule['on'], 'action' => $first['effect'], 'account_id' => $first['account_id'] ?? $fallback->id, 'do' => $second['effect'], 'offset_account_id' => $second['account_id'] ?? $fallback->id, 'active' => $rule['active'] ?? true, ] ); $transactionRule->save(); $retainedLineIds = []; foreach ( $rule['lines'] as $index => $line ) { $ruleLine = isset( $line['id'] ) ? $transactionRule->lines()->whereKey( $line['id'] )->first() : null; $ruleLine ??= $transactionRule->lines()->make(); $ruleLine->fill( [ 'account_id' => $line['account_id'] ?? null, 'dynamic_account_role' => $line['dynamic_account_role'] ?? null, 'effect' => $line['effect'], 'amount_source' => $line['amount_source'], 'display_order' => $index, ] ); $ruleLine->save(); $retainedLineIds[] = $ruleLine->id; } $transactionRule->lines()->whereNotIn( 'id', $retainedLineIds )->delete(); return $transactionRule->load( 'lines.account' ); } ); return [ 'status' => 'success', 'message' => __( 'The transaction rule has been saved.' ), 'data' => [ 'rule' => $transactionRule ], ]; } public function deleteTransactionRule( TransactionActionRule $rule ): array { if ( $rule->locked ) { throw new NotAllowedException( __( 'This accounting rule cannot be deleted.' ) ); } DB::transaction( function () use ( $rule ): void { if ( $rule->journals()->exists() ) { $rule->active = false; $rule->save(); return; } $rule->lines()->delete(); $rule->delete(); } ); return [ 'status' => 'success', 'message' => __( 'The transaction rule has been deleted.' ), ]; } /** * Provides transaction account using category identifiery * * @param string $category_identifier * @param int $exclude_id * @return array */ public function getTransactionAccountFromCategory( $category_identifier, $exclude_id = null ) { $query = TransactionAccount::where( 'category_identifier', $category_identifier ); if ( ! empty( $exclude_id ) ) { $query->where( 'id', '!=', $exclude_id ); } $accounts = $query->get(); return Helper::toJsOptions( $accounts, [ 'id', 'name' ] ); } /** * Safely upgrades an existing installation and provisions fresh installations. */ public function upgradeAccountingFoundation(): void { DB::transaction( function (): void { $accounts = $this->provisionAccountingChart(); $this->migrateLegacyRulePairs(); $this->provisionLegacyCompatibilityRules( $accounts ); $this->provisionGroupedAccountingRules( $accounts ); $this->configureDefaultPaymentAccounts( $accounts ); } ); app( AccountingJournalService::class )->postOpeningBalance(); } /** * @return array */ private function provisionAccountingChart(): array { $chart = [ 'fixed_assets' => [ '1001', 'Fixed Assets', 'assets', null ], 'current_assets' => [ '1002', 'Current Assets', 'assets', null ], 'inventory' => [ '1003', 'Inventory', 'assets', 'current_assets' ], 'cash' => [ '1004', 'Cash', 'assets', 'current_assets' ], 'bank' => [ '1005', 'Bank', 'assets', 'current_assets' ], 'accounts_receivable' => [ '1006', 'Accounts Receivable', 'assets', 'current_assets' ], 'payment_clearing' => [ '1007', 'Payment Clearing', 'assets', 'current_assets' ], 'current_liabilities' => [ '2001', 'Current Liabilities', 'liabilities', null ], 'accounts_payable' => [ '2002', 'Accounts Payable', 'liabilities', 'current_liabilities' ], 'sales_tax_payable' => [ '2003', 'Sales Tax Payable', 'liabilities', 'current_liabilities' ], 'customer_deposits' => [ '2004', 'Customer Deposits', 'liabilities', 'current_liabilities' ], 'owner_capital' => [ '3001', 'Owner Capital', 'equity', null ], 'owner_drawings' => [ '3002', 'Owner Drawings', 'equity', null ], 'retained_earnings' => [ '3003', 'Retained Earnings', 'equity', null ], 'sales_revenue' => [ '4001', 'Sales Revenue', 'revenues', null ], 'sales_returns' => [ '4002', 'Sales Returns / Refunds', 'revenues', null ], 'cogs' => [ '5001', 'Cost of Goods Sold', 'expenses', null ], 'operating_expenses' => [ '5100', 'Operating Expenses', 'expenses', null ], 'rent' => [ '5101', 'Rent', 'expenses', 'operating_expenses' ], 'salaries_wages' => [ '5102', 'Salaries & Wages', 'expenses', 'operating_expenses' ], 'utilities' => [ '5103', 'Utilities', 'expenses', 'operating_expenses' ], 'maintenance' => [ '5104', 'Maintenance', 'expenses', 'operating_expenses' ], 'other_expenses' => [ '5105', 'Other Expenses', 'expenses', 'operating_expenses' ], 'inventory_variance' => [ '5106', 'Inventory Variance', 'expenses', 'operating_expenses' ], ]; $legacyNames = [ 'inventory' => [ 'Inventory Account' ], 'cash' => [ 'Procurement Cash', 'Cash' ], 'accounts_payable' => [ 'Procurement Payable', 'Account Payable' ], 'accounts_receivable' => [ 'Receivables' ], 'payment_clearing' => [ 'Sales' ], 'sales_revenue' => [ 'Sales Revenues' ], 'sales_returns' => [ 'Refunds' ], 'cogs' => [ 'Sales COGS' ], ]; $accounts = []; foreach ( $chart as $identifier => [ $code, $name, $category ] ) { $account = TransactionAccount::query()->where( 'system_identifier', $identifier )->first(); if ( ! $account instanceof TransactionAccount && isset( $legacyNames[ $identifier ] ) ) { $account = TransactionAccount::query() ->whereNull( 'system_identifier' ) ->whereIn( 'name', $legacyNames[ $identifier ] ) ->where( 'category_identifier', $category ) ->whereDoesntHave( 'histories' ) ->get() ->first( fn( TransactionAccount $candidate ): bool => preg_match( '/^\\d+-' . preg_quote( $category, '/' ) . '-/', (string) $candidate->account ) === 1 ); if ( $account instanceof TransactionAccount ) { $account->system_identifier = $identifier; $account->account = $code; $account->name = __( $name ); $account->save(); } } if ( ! $account instanceof TransactionAccount ) { $account = new TransactionAccount; $account->system_identifier = $identifier; $account->account = $code; $account->name = __( $name ); $account->category_identifier = $category; $account->author_id = ns()->getValidAuthor(); $account->save(); } $accounts[ $identifier ] = $account; } foreach ( $chart as $identifier => [ , , , $parentIdentifier ] ) { if ( $parentIdentifier !== null && $accounts[ $identifier ]->sub_category_id !== $accounts[ $parentIdentifier ]->id ) { $accounts[ $identifier ]->sub_category_id = $accounts[ $parentIdentifier ]->id; $accounts[ $identifier ]->save(); } } return $accounts; } private function migrateLegacyRulePairs(): void { TransactionActionRule::query()->orderBy( 'id' )->get()->groupBy( 'on' )->each( function ( $rules ): void { $keeper = $rules->first(); $nextOrder = $keeper->lines()->max( 'display_order' ) ?? -1; foreach ( $rules as $rule ) { if ( $rule->lines()->doesntExist() ) { foreach ( [ [ 'account_id' => $rule->account_id, 'effect' => $rule->action ], [ 'account_id' => $rule->offset_account_id, 'effect' => $rule->do ], ] as $legacyLine ) { $keeper->lines()->create( array_merge( $legacyLine, [ 'amount_source' => 'total', 'display_order' => ++$nextOrder, ] ) ); } } elseif ( $rule->id !== $keeper->id ) { foreach ( $rule->lines as $line ) { $keeper->lines()->create( [ 'account_id' => $line->account_id, 'dynamic_account_role' => $line->dynamic_account_role, 'effect' => $line->effect, 'amount_source' => $line->amount_source, 'display_order' => ++$nextOrder, ] ); } } if ( $rule->id !== $keeper->id ) { $rule->active = false; $rule->save(); } } $keeper->active = true; $keeper->save(); } ); } /** * @param array $accounts */ private function provisionLegacyCompatibilityRules( array $accounts ): void { $rules = [ self::legacyRule( TransactionActionRule::RULE_PROCUREMENT_UNPAID, $accounts['inventory'], 'increase', $accounts['accounts_payable'], 'increase' ), self::legacyRule( TransactionActionRule::RULE_PROCUREMENT_PAID, $accounts['inventory'], 'increase', $accounts['payment_clearing'], 'decrease' ), self::legacyRule( TransactionActionRule::RULE_PROCUREMENT_FROM_UNPAID_TO_PAID, $accounts['accounts_payable'], 'decrease', $accounts['payment_clearing'], 'decrease' ), self::legacyRule( TransactionActionRule::RULE_ORDER_UNPAID, $accounts['accounts_receivable'], 'increase', $accounts['sales_revenue'], 'increase' ), self::legacyRule( TransactionActionRule::RULE_ORDER_FROM_UNPAID_TO_PAID, $accounts['payment_clearing'], 'increase', $accounts['accounts_receivable'], 'decrease' ), self::legacyRule( TransactionActionRule::RULE_ORDER_PAID, $accounts['payment_clearing'], 'increase', $accounts['sales_revenue'], 'increase' ), self::legacyRule( TransactionActionRule::RULE_ORDER_PARTIALLY_PAID, $accounts['payment_clearing'], 'increase', $accounts['sales_revenue'], 'increase' ), self::legacyRule( TransactionActionRule::RULE_ORDER_REFUNDED, $accounts['sales_returns'], 'decrease', $accounts['payment_clearing'], 'decrease' ), self::legacyRule( TransactionActionRule::RULE_ORDER_PARTIALLY_REFUNDED, $accounts['sales_returns'], 'decrease', $accounts['payment_clearing'], 'decrease' ), self::legacyRule( TransactionActionRule::RULE_ORDER_COGS, $accounts['cogs'], 'increase', $accounts['inventory'], 'decrease' ), self::legacyRule( TransactionActionRule::RULE_PRODUCT_DAMAGED, $accounts['inventory_variance'], 'increase', $accounts['inventory'], 'decrease' ), self::legacyRule( TransactionActionRule::RULE_PRODUCT_RETURNED, $accounts['cogs'], 'decrease', $accounts['inventory'], 'increase' ), self::legacyRule( TransactionActionRule::RULE_ORDER_PAID_VOIDED, $accounts['sales_returns'], 'decrease', $accounts['payment_clearing'], 'decrease' ), self::legacyRule( TransactionActionRule::RULE_ORDER_UNPAID_VOIDED, $accounts['sales_returns'], 'decrease', $accounts['accounts_receivable'], 'decrease' ), ]; foreach ( $rules as $definition ) { if ( TransactionActionRule::query()->where( 'on', $definition['on'] )->exists() ) { continue; } $this->createRuleGroup( $definition['on'], $definition['lines'] ); } } /** * @param array $accounts */ private function provisionGroupedAccountingRules( array $accounts ): void { $groups = [ AccountingEventCatalog::ORDER_FINALIZED => [ [ $accounts['accounts_receivable'], null, 'increase', 'total' ], [ $accounts['sales_revenue'], null, 'increase', 'net_sale' ], [ $accounts['sales_tax_payable'], null, 'increase', 'tax' ], [ $accounts['cogs'], null, 'increase', 'cogs' ], [ $accounts['inventory'], null, 'decrease', 'cogs' ], ], AccountingEventCatalog::ORDER_PAYMENT => [ [ null, 'payment_account', 'increase', 'payment_amount' ], [ $accounts['accounts_receivable'], null, 'decrease', 'payment_amount' ], ], AccountingEventCatalog::ORDER_REFUND => [ [ $accounts['sales_returns'], null, 'decrease', 'net_refund' ], [ $accounts['sales_tax_payable'], null, 'decrease', 'refunded_tax' ], [ $accounts['accounts_receivable'], null, 'decrease', 'refund_unpaid' ], [ null, 'refund_payment_account', 'decrease', 'refund_paid' ], ], AccountingEventCatalog::RETURN_GOOD => [ [ $accounts['inventory'], null, 'increase', 'refund_cost' ], [ $accounts['cogs'], null, 'decrease', 'refund_cost' ], ], AccountingEventCatalog::RETURN_DAMAGED => [ [ $accounts['inventory_variance'], null, 'increase', 'refund_cost' ], [ $accounts['cogs'], null, 'decrease', 'refund_cost' ], ], AccountingEventCatalog::PROCUREMENT_RECEIPT => [ [ $accounts['inventory'], null, 'increase', 'procurement_cost' ], [ $accounts['accounts_payable'], null, 'increase', 'procurement_cost' ], ], AccountingEventCatalog::PROCUREMENT_PAYMENT => [ [ $accounts['accounts_payable'], null, 'decrease', 'payment_amount' ], [ $accounts['payment_clearing'], null, 'decrease', 'payment_amount' ], ], AccountingEventCatalog::ADJUSTMENT_NEGATIVE => [ [ $accounts['inventory_variance'], null, 'increase', 'adjustment_cost' ], [ $accounts['inventory'], null, 'decrease', 'adjustment_cost' ], ], AccountingEventCatalog::ADJUSTMENT_POSITIVE => [ [ $accounts['inventory'], null, 'increase', 'adjustment_cost' ], [ $accounts['inventory_variance'], null, 'decrease', 'adjustment_cost' ], ], AccountingEventCatalog::WALLET_ADDITION => [ [ $accounts['payment_clearing'], null, 'increase', 'wallet_amount' ], [ $accounts['customer_deposits'], null, 'increase', 'wallet_amount' ], ], AccountingEventCatalog::WALLET_DEDUCTION => [ [ $accounts['customer_deposits'], null, 'decrease', 'wallet_amount' ], [ $accounts['payment_clearing'], null, 'decrease', 'wallet_amount' ], ], ]; foreach ( $groups as $event => $lines ) { if ( TransactionActionRule::query()->where( 'on', $event )->exists() ) { continue; } $this->createRuleGroup( $event, $lines ); } } /** * @param array $lines */ private function createRuleGroup( string $event, array $lines ): TransactionActionRule { $fallback = TransactionAccount::query()->where( 'system_identifier', 'payment_clearing' )->firstOrFail(); $first = $lines[0]; $second = $lines[1]; $rule = TransactionActionRule::query()->create( [ 'on' => $event, 'action' => $first[2], 'account_id' => ( $first[0] ?? $fallback )->id, 'do' => $second[2], 'offset_account_id' => ( $second[0] ?? $fallback )->id, 'locked' => false, 'active' => true, ] ); foreach ( $lines as $index => [ $account, $role, $effect, $source ] ) { $rule->lines()->create( [ 'account_id' => $account?->id, 'dynamic_account_role' => $role, 'effect' => $effect, 'amount_source' => $source, 'display_order' => $index, ] ); } return $rule; } /** * @return array{on: string, lines: array} */ private static function legacyRule( string $event, TransactionAccount $account, string $effect, TransactionAccount $offset, string $offsetEffect ): array { return [ 'on' => $event, 'lines' => [ [ $account, null, $effect, 'total' ], [ $offset, null, $offsetEffect, 'total' ], ], ]; } /** * @param array $accounts */ private function configureDefaultPaymentAccounts( array $accounts ): void { $defaults = [ 'cash-payment' => [ $accounts['cash']->id, 'increase' ], 'bank-payment' => [ $accounts['bank']->id, 'increase' ], 'account-payment' => [ $accounts['customer_deposits']->id, 'decrease' ], ]; foreach ( $defaults as $identifier => [ $accountId, $effect ] ) { $paymentType = PaymentType::query()->where( 'identifier', $identifier )->first(); if ( $paymentType && $paymentType->accounting_account_id === null ) { $paymentType->accounting_account_id = $accountId; $paymentType->accounting_incoming_effect = $effect; $paymentType->save(); } } ns()->option->set( 'ns_accounting_default_paid_expense_offset_account', $accounts['payment_clearing']->id ); } }