From 6eeebfeacb582e841a445dfa254b8f01dbeaf27d Mon Sep 17 00:00:00 2001 From: Thanakorn S Date: Thu, 21 May 2026 11:42:47 +0700 Subject: [PATCH] 1) user invitation 2) app access control 3) txn quota guard --- app/ac_dashboard/api/engine/by_source.php | 3 + app/ac_dashboard/api/engine/journals.php | 3 + app/ac_dashboard/api/engine/pl.php | 3 + app/ac_dashboard/api/engine/recent.php | 3 + app/ac_dashboard/api/engine/trend.php | 3 + .../api/engine/get_balance_sheet.php | 3 + app/accounting/api/engine/get_gl_movement.php | 3 + .../api/engine/get_pl_statement.php | 3 + .../api/engine/get_trial_balance.php | 3 + app/accounting/api/engine/get_vat_report.php | 3 + app/assets/js/custom.js | 14 ++ app/assets/utils/classes/ContactManager.php | 12 +- app/assets/utils/classes/ProductManager.php | 24 ++- app/assets/utils/classes/UsageGuard.php | 99 +++++++++ app/assets/utils/classes/UserManager.php | 198 +++++++++++++++--- app/assets/utils/classes_ac/GlManager.php | 40 +++- .../api/engine_report/reports_stats.php | 3 + app/finance/api/engine/manage_payment.php | 2 + app/finance/api/engine/manage_receipt.php | 2 + app/ics/api/engine/manage_stock_in.php | 4 +- app/ics/api/engine/manage_stock_out.php | 4 +- app/ics/api/engine/manage_stock_transfer.php | 4 +- app/include_ending.php | 5 +- app/include_topbar.php | 25 +++ app/inventory/api/engine/manage_storage.php | 2 +- app/login/api/engine/invited_onboarding.php | 149 +++++++++++++ app/login/api/engine/login_confirm.php | 14 +- app/login/api/engine/onboarding.php | 12 +- app/login/invited_onboarding.php | 193 +++++++++++++++++ app/order/api/engine/confirm_order.php | 2 +- app/order/api/engine/confirm_return.php | 2 +- app/order/api/engine/manage_invoice.php | 6 + app/order/api/engine/manage_order.php | 2 + app/order/confirm_order.php | 2 +- app/po/api/engine/confirm_supplier_return.php | 2 +- app/po/api/engine/manage_po.php | 2 + app/po/api/engine/receive_po.php | 2 +- .../api/engine_report/expired_stock.php | 3 + .../api/engine_report/lot_stock_log.php | 3 + app/reports/api/engine_report/product_lot.php | 3 + app/reports/api/engine_report/rack_log.php | 3 + .../api/engine_report/rack_occupancy.php | 3 + .../api/engine_report/stock_movement.php | 3 + .../api/engine_report/stock_movement_sku.php | 3 + app/reports/stock_movement.php | 2 +- app/revenue/api/engine/manage_order.php | 2 + app/revenue/api/engine/manage_quotation.php | 2 + app/setting/api/engine/manage_users.php | 102 ++++++++- app/setting/api/engine/switch_branch.php | 6 + app/setting/users.php | 184 +++++++++++----- 50 files changed, 1062 insertions(+), 113 deletions(-) create mode 100644 app/assets/utils/classes/UsageGuard.php create mode 100644 app/login/api/engine/invited_onboarding.php create mode 100644 app/login/invited_onboarding.php diff --git a/app/ac_dashboard/api/engine/by_source.php b/app/ac_dashboard/api/engine/by_source.php index cfa3f1c..0de7653 100644 --- a/app/ac_dashboard/api/engine/by_source.php +++ b/app/ac_dashboard/api/engine/by_source.php @@ -1,6 +1,9 @@ assertFeatureAccessible('dashboard'); require '../../../assets/utils/classes_ac/FinancialReports.php'; $reports = new FinancialReports($pdo2, $company_id); diff --git a/app/ac_dashboard/api/engine/journals.php b/app/ac_dashboard/api/engine/journals.php index c4af076..144a4d7 100644 --- a/app/ac_dashboard/api/engine/journals.php +++ b/app/ac_dashboard/api/engine/journals.php @@ -1,6 +1,9 @@ assertFeatureAccessible('dashboard'); require '../../../assets/utils/classes_ac/FinancialReports.php'; $reports = new FinancialReports($pdo2, $company_id); diff --git a/app/ac_dashboard/api/engine/pl.php b/app/ac_dashboard/api/engine/pl.php index ee36f28..7d631c7 100644 --- a/app/ac_dashboard/api/engine/pl.php +++ b/app/ac_dashboard/api/engine/pl.php @@ -1,6 +1,9 @@ assertFeatureAccessible('dashboard'); require '../../../assets/utils/classes_ac/FinancialReports.php'; $reports = new FinancialReports($pdo2, $company_id); diff --git a/app/ac_dashboard/api/engine/recent.php b/app/ac_dashboard/api/engine/recent.php index 55417a8..4b61848 100644 --- a/app/ac_dashboard/api/engine/recent.php +++ b/app/ac_dashboard/api/engine/recent.php @@ -1,6 +1,9 @@ assertFeatureAccessible('dashboard'); require '../../../assets/utils/classes_ac/FinancialReports.php'; $limit = max(1, min(50, (int)($data['limit'] ?? 10))); diff --git a/app/ac_dashboard/api/engine/trend.php b/app/ac_dashboard/api/engine/trend.php index 34ed76a..8a063b8 100644 --- a/app/ac_dashboard/api/engine/trend.php +++ b/app/ac_dashboard/api/engine/trend.php @@ -1,6 +1,9 @@ assertFeatureAccessible('dashboard'); require '../../../assets/utils/classes_ac/FinancialReports.php'; $months = max(1, min(24, (int)($data['months'] ?? 6))); diff --git a/app/accounting/api/engine/get_balance_sheet.php b/app/accounting/api/engine/get_balance_sheet.php index dfe4394..a2dfa83 100644 --- a/app/accounting/api/engine/get_balance_sheet.php +++ b/app/accounting/api/engine/get_balance_sheet.php @@ -1,6 +1,9 @@ assertFeatureAccessible('reports'); require '../../../assets/utils/classes_ac/FinancialReports.php'; $as_of_date = trim((string)($data['as_of_date'] ?? ($data['as_of_period'] ?? date('Y-m-d')))); diff --git a/app/accounting/api/engine/get_gl_movement.php b/app/accounting/api/engine/get_gl_movement.php index 35ec4ae..956d0f2 100644 --- a/app/accounting/api/engine/get_gl_movement.php +++ b/app/accounting/api/engine/get_gl_movement.php @@ -1,6 +1,9 @@ assertFeatureAccessible('reports'); require '../../../assets/utils/classes_ac/FinancialReports.php'; $account_code = trim((string)($data['account_code'] ?? '')); diff --git a/app/accounting/api/engine/get_pl_statement.php b/app/accounting/api/engine/get_pl_statement.php index b3a921e..4a7fe32 100644 --- a/app/accounting/api/engine/get_pl_statement.php +++ b/app/accounting/api/engine/get_pl_statement.php @@ -1,6 +1,9 @@ assertFeatureAccessible('reports'); require '../../../assets/utils/classes_ac/FinancialReports.php'; $from_date = trim((string)($data['from_date'] ?? ($data['from_period'] ?? date('Y-m-d')))); diff --git a/app/accounting/api/engine/get_trial_balance.php b/app/accounting/api/engine/get_trial_balance.php index fff2795..4a18c8d 100644 --- a/app/accounting/api/engine/get_trial_balance.php +++ b/app/accounting/api/engine/get_trial_balance.php @@ -1,6 +1,9 @@ assertFeatureAccessible('reports'); require '../../../assets/utils/classes_ac/FinancialReports.php'; $from_date = trim((string)($data['from_date'] ?? ($data['from_period'] ?? date('Y-m-d')))); diff --git a/app/accounting/api/engine/get_vat_report.php b/app/accounting/api/engine/get_vat_report.php index dea66f3..cc0250b 100644 --- a/app/accounting/api/engine/get_vat_report.php +++ b/app/accounting/api/engine/get_vat_report.php @@ -1,6 +1,9 @@ assertFeatureAccessible('reports'); require '../../../assets/utils/classes_ac/TaxReportManager.php'; $from_date = trim((string)($data['from_date'] ?? ($data['from_period'] ?? date('Y-m-d')))); diff --git a/app/assets/js/custom.js b/app/assets/js/custom.js index f5eeee2..7d8186c 100644 --- a/app/assets/js/custom.js +++ b/app/assets/js/custom.js @@ -820,6 +820,20 @@ function ajax_request(options) { throw xhr; } + // Usage limit reached — show upgrade notice instead of generic error + if (xhr?.status === 402) { + const d = xhr?.responseJSON ?? {}; + const daily = d.daily_limit > 0 ? `Daily: ${d.daily_count} / ${d.daily_limit}` : null; + const weekly = d.weekly_limit > 0 ? `Weekly: ${d.weekly_count} / ${d.weekly_limit}` : null; + const detail = [daily, weekly].filter(Boolean).join('  |  '); + bootbox.alert( + `Usage limit reached.
${detail}

` + + `Reports are locked until the quota resets. Upgrade your package for higher limits.` + ); + options.onError?.(xhr, 'limit_reached'); + throw xhr; + } + // Extract server's error message from JSON response let serverMessage = xhr?.responseJSON?.message; diff --git a/app/assets/utils/classes/ContactManager.php b/app/assets/utils/classes/ContactManager.php index 0b5e52a..1fd61d6 100644 --- a/app/assets/utils/classes/ContactManager.php +++ b/app/assets/utils/classes/ContactManager.php @@ -148,7 +148,17 @@ class ContactManager { */ public function saveContactType(array $data, array $logging): void { - $id = (int)($data['id'] ?? 0); + $id = (int)($data['id'] ?? 0); + $name = (string)($data['contact_type'] ?? ''); + + $dupSth = $this->pdo->prepare( + "SELECT id FROM md_contact_type + WHERE company_id = :cid AND contact_type = :name" . ($id > 0 ? " AND id != :id" : "") . " LIMIT 1" + ); + $dupParams = [':cid' => $this->company_id, ':name' => $name]; + if ($id > 0) $dupParams[':id'] = $id; + $dupSth->execute($dupParams); + if ($dupSth->fetchColumn()) throw new Exception("A contact type with this name already exists."); $sth = $this->pdo->prepare( "SELECT `log` FROM md_contact_type diff --git a/app/assets/utils/classes/ProductManager.php b/app/assets/utils/classes/ProductManager.php index ff7f8e2..ba384a1 100644 --- a/app/assets/utils/classes/ProductManager.php +++ b/app/assets/utils/classes/ProductManager.php @@ -154,7 +154,17 @@ class ProductManager { */ public function saveCategory(array $data, array $logging): void { - $id = (int)($data['id'] ?? 0); + $id = (int)($data['id'] ?? 0); + $slug = (string)($data['slug'] ?? ''); + + $dupSth = $this->pdo->prepare( + "SELECT id FROM md_product_category + WHERE company_id = :cid AND slug = :slug" . ($id > 0 ? " AND id != :id" : "") . " LIMIT 1" + ); + $dupParams = [':cid' => $this->company_id, ':slug' => $slug]; + if ($id > 0) $dupParams[':id'] = $id; + $dupSth->execute($dupParams); + if ($dupSth->fetchColumn()) throw new Exception("A category with this slug already exists."); $sth = $this->pdo->prepare( "SELECT `log` FROM md_product_category @@ -355,7 +365,17 @@ class ProductManager { */ public function saveProduct(array $data, array $logging, string $product_image): void { - $id = (int)($data['id'] ?? 0); + $id = (int)($data['id'] ?? 0); + $sku = (string)($data['sku'] ?? ''); + + $dupSth = $this->pdo->prepare( + "SELECT id FROM md_product + WHERE company_id = :cid AND sku = :sku" . ($id > 0 ? " AND id != :id" : "") . " LIMIT 1" + ); + $dupParams = [':cid' => $this->company_id, ':sku' => $sku]; + if ($id > 0) $dupParams[':id'] = $id; + $dupSth->execute($dupParams); + if ($dupSth->fetchColumn()) throw new Exception("A product with this SKU already exists."); $sth = $this->pdo->prepare( "SELECT `log` FROM md_product diff --git a/app/assets/utils/classes/UsageGuard.php b/app/assets/utils/classes/UsageGuard.php new file mode 100644 index 0000000..2273417 --- /dev/null +++ b/app/assets/utils/classes/UsageGuard.php @@ -0,0 +1,99 @@ +pdo = $pdo1; + $this->company_id = $company_id; + $this->pkg = $this->resolvePackage($packages); + } + + private function resolvePackage(array $packages): array { + $sth = $this->pdo->prepare( + "SELECT package FROM company_list WHERE company_id = :cid LIMIT 1" + ); + $sth->execute([':cid' => $this->company_id]); + $name = $sth->fetchColumn() ?: 'starter'; + return $packages[$name] ?? $packages['starter'] ?? [ + 'daily_limit' => 30, + 'weekly_limit' => 100, + 'lock_on_limit' => ['dashboard'], + ]; + } + + public function increment(): void { + $today = date('Y-m-d'); + $this->pdo->prepare( + "INSERT INTO company_usage (company_id, day_date, daily_count) + VALUES (:cid, :today, 1) + ON DUPLICATE KEY UPDATE daily_count = daily_count + 1" + )->execute([':cid' => $this->company_id, ':today' => $today]); + } + + public function assertFeatureAccessible(string $feature): void { + $lock_on = $this->pkg['lock_on_limit'] ?? []; + if (!in_array($feature, $lock_on, true)) { + return; + } + + $status = $this->getStatus(); + $daily_limit = (int)($this->pkg['daily_limit'] ?? 0); + $weekly_limit = (int)($this->pkg['weekly_limit'] ?? 0); + + $over_daily = $daily_limit > 0 && $status['daily_count'] >= $daily_limit; + $over_weekly = $weekly_limit > 0 && $status['weekly_count'] >= $weekly_limit; + + if ($over_daily || $over_weekly) { + http_response_code(402); + exit(json_encode([ + 'success' => 0, + 'message' => 'Usage limit reached. Upgrade your package to access reports.', + 'limit_reached' => true, + 'daily_count' => $status['daily_count'], + 'daily_limit' => $status['daily_limit'], + 'weekly_count' => $status['weekly_count'], + 'weekly_limit' => $status['weekly_limit'], + ])); + } + } + + public function getStatus(): array { + $today = date('Y-m-d'); + $day_of_week = (int)date('N'); + $week_start = date('Y-m-d', strtotime('-' . ($day_of_week - 1) . ' days')); + + $sth = $this->pdo->prepare( + "SELECT daily_count FROM company_usage + WHERE company_id = :cid AND day_date = :today LIMIT 1" + ); + $sth->execute([':cid' => $this->company_id, ':today' => $today]); + $daily_count = (int)($sth->fetchColumn() ?: 0); + + $sth = $this->pdo->prepare( + "SELECT COALESCE(SUM(daily_count), 0) FROM company_usage + WHERE company_id = :cid AND day_date BETWEEN :wstart AND :today" + ); + $sth->execute([ + ':cid' => $this->company_id, + ':wstart'=> $week_start, + ':today' => $today, + ]); + $weekly_count = (int)$sth->fetchColumn(); + + $daily_limit = (int)($this->pkg['daily_limit'] ?? 0); + $weekly_limit = (int)($this->pkg['weekly_limit'] ?? 0); + + return [ + 'daily_count' => $daily_count, + 'weekly_count' => $weekly_count, + 'daily_limit' => $daily_limit, + 'weekly_limit' => $weekly_limit, + 'daily_pct' => $daily_limit > 0 ? min(100, (int)round($daily_count / $daily_limit * 100)) : 0, + 'weekly_pct' => $weekly_limit > 0 ? min(100, (int)round($weekly_count / $weekly_limit * 100)) : 0, + ]; + } +} diff --git a/app/assets/utils/classes/UserManager.php b/app/assets/utils/classes/UserManager.php index 584081e..7ebefda 100644 --- a/app/assets/utils/classes/UserManager.php +++ b/app/assets/utils/classes/UserManager.php @@ -118,9 +118,10 @@ class UserManager { public function getCompanyAccess(int $company_id): ?array { $sth = $this->pdo->prepare( - "SELECT company_id, role - FROM company_map_user - WHERE company_id = :company_id AND user_id = :user_id + "SELECT m.company_id, m.role, m.app_access, u.license, u.app_access AS user_app_access + FROM company_map_user m + JOIN user u ON u.user_id = m.user_id + WHERE m.company_id = :company_id AND m.user_id = :user_id LIMIT 1" ); $sth->execute([':company_id' => $company_id, ':user_id' => $this->user_id]); @@ -146,13 +147,16 @@ class UserManager { "SELECT m.map_id, m.role, + CASE WHEN u.license = 'owner' THEN u.app_access ELSE m.app_access END AS app_access, m.created_at, u.user_id, u.username, u.name, u.surname, u.email, - u.profile_picture + u.profile_picture, + u.status, + (m.invite_token IS NOT NULL) AS is_pending_invite FROM company_map_user m JOIN user u ON u.user_id = m.user_id WHERE m.company_id = :company_id @@ -194,17 +198,18 @@ class UserManager { } /** - * Add a registered user to the current company by email. + * Add a user to the current company by email. * - * Validates email format, role, and that the target account exists. - * Blocks inviting self or someone already mapped to the company. + * If the email is already registered: maps them to this company directly. + * If the email is not registered: creates a pending account (license='user', + * default_company = this company) and returns an invite token so the caller + * can email them a link to invited_onboarding.php to complete registration. + * The invited user inherits this company's SMTP for OTP login. * - * @param string $email Email address of the user to invite. - * @param string $role Role to assign: 'admin', 'staff', or 'viewer'. - * @return string The invited user's email, for use in the success message. + * @return array ['new_user' => bool, 'email' => string, 'token' => string|null] * @throws Exception On any validation or constraint failure. */ - public function inviteUser(string $email, string $role): string { + public function inviteUser(string $email, string $role, string $app_access): array { if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { throw new Exception('Invalid email address.'); } @@ -220,36 +225,114 @@ class UserManager { $sth->execute([':email' => $email]); $target = $sth->fetch(PDO::FETCH_ASSOC); - if (!$target) { - throw new Exception('No registered account found with that email address.'); + if ($target) { + $target_user_id = (int)$target['user_id']; + + if ($target_user_id === $this->user_id) { + throw new Exception('You cannot invite yourself.'); + } + + $sth = $this->pdo->prepare( + "SELECT map_id FROM company_map_user + WHERE company_id = :company_id AND user_id = :user_id + LIMIT 1" + ); + $sth->execute([':company_id' => $this->company_id, ':user_id' => $target_user_id]); + if ($sth->fetch()) { + throw new Exception('This user is already a member of your company.'); + } + + $this->pdo->prepare( + "INSERT INTO company_map_user (company_id, user_id, role, app_access, created_at) + VALUES (:company_id, :user_id, :role, :app_access, NOW())" + )->execute([ + ':company_id' => $this->company_id, + ':user_id' => $target_user_id, + ':role' => $role, + ':app_access' => $app_access, + ]); + + return ['new_user' => false, 'email' => $target['email'], 'token' => null]; } - $target_user_id = (int)$target['user_id']; - - if ($target_user_id === $this->user_id) { - throw new Exception('You cannot invite yourself.'); - } - - $sth = $this->pdo->prepare( - "SELECT map_id FROM company_map_user - WHERE company_id = :company_id AND user_id = :user_id - LIMIT 1" - ); - $sth->execute([':company_id' => $this->company_id, ':user_id' => $target_user_id]); - if ($sth->fetch()) { - throw new Exception('This user is already a member of your company.'); - } + // Email not in system — create a pending invited account + $invite_token = bin2hex(random_bytes(32)); + $expires_at = date('Y-m-d H:i:s', strtotime('+7 days')); + $temp_username = 'invited_' . bin2hex(random_bytes(8)); $this->pdo->prepare( - "INSERT INTO company_map_user (company_id, user_id, role, created_at) - VALUES (:company_id, :user_id, :role, NOW())" + "INSERT INTO user + (username, name, surname, email, password, status, license, default_company, + profile_picture, verify_token, verify_expires_at) + VALUES + (:username, '', '', :email, '', 'pending', 'user', :default_company, + '', :token, :expires)" + )->execute([ + ':username' => $temp_username, + ':email' => $email, + ':default_company' => $this->company_id, + ':token' => $invite_token, + ':expires' => $expires_at, + ]); + $new_user_id = (int)$this->pdo->lastInsertId(); + + $this->pdo->prepare( + "INSERT INTO company_map_user (company_id, user_id, role, app_access, invite_token, created_at) + VALUES (:company_id, :user_id, :role, :app_access, :token, NOW())" )->execute([ ':company_id' => $this->company_id, - ':user_id' => $target_user_id, + ':user_id' => $new_user_id, ':role' => $role, + ':app_access' => $app_access, + ':token' => $invite_token, ]); - return $target['email']; + return ['new_user' => true, 'email' => $email, 'token' => $invite_token]; + } + + /** + * Regenerate an invite token for a pending invited user and return it. + * + * Only works on license='user' + status='pending' accounts that still have + * an invite_token in company_map_user. Owner-pending accounts (mid-onboarding) + * are never touched. + * + * @param int $map_id The company_map_user.map_id of the pending member. + * @return array ['email' => string, 'token' => string] + * @throws Exception If the member is not found or is not a pending invite. + */ + public function resendInvite(int $map_id): array { + if (!$map_id) throw new Exception('Invalid request.'); + + $sth = $this->pdo->prepare( + "SELECT u.user_id, u.email, u.status, u.license, m.invite_token + FROM company_map_user m + JOIN user u ON u.user_id = m.user_id + WHERE m.map_id = :map_id AND m.company_id = :company_id + LIMIT 1" + ); + $sth->execute([':map_id' => $map_id, ':company_id' => $this->company_id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row) throw new Exception('User not found.'); + if ($row['license'] !== 'user') throw new Exception('Cannot resend invite to an owner account.'); + if ($row['status'] !== 'pending') throw new Exception('User has already accepted the invitation.'); + if (!$row['invite_token']) throw new Exception('No pending invitation found for this user.'); + + $new_token = bin2hex(random_bytes(32)); + $expires_at = date('Y-m-d H:i:s', strtotime('+7 days')); + + $this->pdo->prepare( + "UPDATE user SET verify_token = :token, verify_expires_at = :expires + WHERE user_id = :uid" + )->execute([':token' => $new_token, ':expires' => $expires_at, ':uid' => (int)$row['user_id']]); + + $this->pdo->prepare( + "UPDATE company_map_user SET invite_token = :token + WHERE map_id = :map_id AND company_id = :company_id" + )->execute([':token' => $new_token, ':map_id' => $map_id, ':company_id' => $this->company_id]); + + return ['email' => $row['email'], 'token' => $new_token]; } /** @@ -290,6 +373,45 @@ class UserManager { ]); } + /** + * Update the app_access of a non-owner company member. + * + * Owner's app_access is managed via user.app_access (their license); it + * cannot be changed here. Caller must have already validated the value is + * within the owner's license. + * + * @param int $map_id The company_map_user.map_id to update. + * @param string $app_access New value: 'wms', 'accounting', or 'all'. + * @throws Exception If the member is not found or is the owner. + */ + public function updateAppAccess(int $map_id, string $app_access): void { + if (!$map_id || $app_access === '') { + throw new Exception('Invalid request.'); + } + + $sth = $this->pdo->prepare( + "SELECT u.license FROM company_map_user m + JOIN user u ON u.user_id = m.user_id + WHERE m.map_id = :map_id AND m.company_id = :company_id + LIMIT 1" + ); + $sth->execute([':map_id' => $map_id, ':company_id' => $this->company_id]); + $license = $sth->fetchColumn(); + + if ($license === false) throw new Exception('User not found.'); + if ($license === 'owner') throw new Exception('Owner app access is determined by their license.'); + + $this->pdo->prepare( + "UPDATE company_map_user + SET app_access = :app_access + WHERE map_id = :map_id AND company_id = :company_id" + )->execute([ + ':app_access' => $app_access, + ':map_id' => $map_id, + ':company_id' => $this->company_id, + ]); + } + /** * Remove a user from the current company. * @@ -318,5 +440,17 @@ class UserManager { "DELETE FROM company_map_user WHERE map_id = :map_id AND company_id = :company_id" )->execute([':map_id' => $map_id, ':company_id' => $this->company_id]); + + // If this was a pending invited account that was never activated, delete + // the placeholder user row so the email is free for future invitations. + $sth = $this->pdo->prepare( + "SELECT license, status FROM user WHERE user_id = :uid LIMIT 1" + ); + $sth->execute([':uid' => (int)$row['user_id']]); + $u = $sth->fetch(PDO::FETCH_ASSOC); + if ($u && $u['license'] === 'user' && $u['status'] === 'pending') { + $this->pdo->prepare("DELETE FROM user WHERE user_id = :uid") + ->execute([':uid' => (int)$row['user_id']]); + } } } diff --git a/app/assets/utils/classes_ac/GlManager.php b/app/assets/utils/classes_ac/GlManager.php index 7043289..9c47523 100644 --- a/app/assets/utils/classes_ac/GlManager.php +++ b/app/assets/utils/classes_ac/GlManager.php @@ -8,7 +8,7 @@ * Lifecycle: * post() — first-time GL creation; throws if a record already exists. * replace() — snapshot current lines into td_gl.history, then delete + re-insert. - * delete() — hard-delete both td_gl and all its td_gl_item rows (used on void). + * delete() — create a reversal journal entry (audit trail), then hard-delete the original. */ class GlManager { @@ -213,7 +213,8 @@ class GlManager public function delete(string $source_type, int $source_id): void { $sth = $this->pdo->prepare( - "SELECT id, journal_date, period FROM td_gl + "SELECT id, reference, description, journal_date, period, formula_id + FROM td_gl WHERE company_id = :cid AND source_type = :source_type AND source_id = :source_id @@ -229,7 +230,40 @@ class GlManager if (!$gl_id) return; $journal_date = $this->resolveJournalDate($gl['journal_date'] ?? null, (string)($gl['period'] ?? '')); - $this->assertPostingWindow($journal_date, 'GL posting deletion'); + $this->assertPostingWindow($journal_date, 'GL void'); + + $original_lines = $this->getLines($gl_id); + + if (!empty($original_lines)) { + $reversal_lines = array_map(fn($line) => [ + 'account_code' => $line['account_code'], + 'department_id' => (int)$line['department_id'], + 'debit' => (float)$line['credit'], + 'credit' => (float)$line['debit'], + 'description' => 'VOID: ' . $line['description'], + ], $original_lines); + + $now = date('Y-m-d H:i:s'); + $this->pdo->prepare( + "INSERT INTO td_gl + (company_id, source_type, source_id, reference, description, journal_date, + formula_id, period, current_version, history, created_at, updated_at) + VALUES + (:cid, 'reversal', :source_id, :reference, :description, :journal_date, + :formula_id, :period, 1, '[]', :created_at, :updated_at)" + )->execute([ + ':cid' => $this->companyId, + ':source_id' => $gl_id, + ':reference' => 'VOID/' . ($gl['reference'] ?? ''), + ':description' => 'Void: ' . ($gl['description'] ?? ''), + ':journal_date' => $journal_date, + ':formula_id' => (int)($gl['formula_id'] ?? 0), + ':period' => $gl['period'] ?? '', + ':created_at' => $now, + ':updated_at' => $now, + ]); + $this->insertLines((int)$this->pdo->lastInsertId(), $reversal_lines); + } $this->pdo->prepare( "DELETE FROM td_gl_item WHERE company_id = :cid AND gl_id = :gl_id" diff --git a/app/dashboard/api/engine_report/reports_stats.php b/app/dashboard/api/engine_report/reports_stats.php index 80067b1..d7a7b55 100644 --- a/app/dashboard/api/engine_report/reports_stats.php +++ b/app/dashboard/api/engine_report/reports_stats.php @@ -1,8 +1,11 @@ assertFeatureAccessible('dashboard'); + $report = new ReportManager($pdo2, $company_id); $answer['output'] = array_merge( diff --git a/app/finance/api/engine/manage_payment.php b/app/finance/api/engine/manage_payment.php index 2959ad2..2fc1112 100644 --- a/app/finance/api/engine/manage_payment.php +++ b/app/finance/api/engine/manage_payment.php @@ -2,6 +2,7 @@ session_start(); require '../../../assets/utils/db_auth.php'; require_role($user_role, ['owner', 'admin', 'staff']); + require '../../../assets/utils/classes/UsageGuard.php'; require '../../../assets/utils/classes/PaymentManager.php'; try { @@ -50,6 +51,7 @@ $answer['success'] = 1; $answer['new_id'] = $new_id; $answer['message'] = 'Payment posted.'; + (new UsageGuard($pdo1, $company_id, $packages))->increment(); exit(json_encode($answer)); } diff --git a/app/finance/api/engine/manage_receipt.php b/app/finance/api/engine/manage_receipt.php index 7ecf9d9..119ce15 100644 --- a/app/finance/api/engine/manage_receipt.php +++ b/app/finance/api/engine/manage_receipt.php @@ -2,6 +2,7 @@ session_start(); require '../../../assets/utils/db_auth.php'; require_role($user_role, ['owner', 'admin', 'staff']); + require '../../../assets/utils/classes/UsageGuard.php'; require '../../../assets/utils/classes/ReceiptManager.php'; try { @@ -50,6 +51,7 @@ $answer['success'] = 1; $answer['new_id'] = $new_id; $answer['message'] = 'Receipt posted.'; + (new UsageGuard($pdo1, $company_id, $packages))->increment(); exit(json_encode($answer)); } diff --git a/app/ics/api/engine/manage_stock_in.php b/app/ics/api/engine/manage_stock_in.php index bd8b509..aa099ca 100644 --- a/app/ics/api/engine/manage_stock_in.php +++ b/app/ics/api/engine/manage_stock_in.php @@ -1,9 +1,10 @@ increment(); } catch (PDOException $e) { $answer['success'] = 0; diff --git a/app/ics/api/engine/manage_stock_out.php b/app/ics/api/engine/manage_stock_out.php index ce4ae06..5669e03 100644 --- a/app/ics/api/engine/manage_stock_out.php +++ b/app/ics/api/engine/manage_stock_out.php @@ -1,9 +1,10 @@ increment(); } catch (PDOException $e) { $answer['success'] = 0; diff --git a/app/ics/api/engine/manage_stock_transfer.php b/app/ics/api/engine/manage_stock_transfer.php index 3a1dbc9..6826e83 100644 --- a/app/ics/api/engine/manage_stock_transfer.php +++ b/app/ics/api/engine/manage_stock_transfer.php @@ -1,9 +1,10 @@ increment(); } catch (PDOException $e) { $answer['success'] = 0; diff --git a/app/include_ending.php b/app/include_ending.php index 07f4988..c03be32 100644 --- a/app/include_ending.php +++ b/app/include_ending.php @@ -1,14 +1,13 @@
-

Copyright © 2026 MN3WMS. Developed by TR3

+

Copyright © 2026 MN3 WMS. All rights reserved.

- +