pdo = $pdo; $this->companyId = $company_id; } // ── read ───────────────────────────────────────────────────────────────── public function getList(int $invoice_id = 0): array { $where = ['a.company_id = :cid']; $params = [':cid' => $this->companyId, ':cid_d' => $this->companyId]; if ($invoice_id > 0) { $where[] = 'a.invoice_id = :invoice_id'; $params[':invoice_id'] = $invoice_id; } $sth = $this->pdo->prepare($this->baseSelect() . ' WHERE ' . implode(' AND ', $where) . ' ORDER BY a.id DESC'); $sth->execute($params); return $sth->fetchAll(PDO::FETCH_ASSOC); } public function getById(int $id): ?array { $sth = $this->pdo->prepare($this->baseSelect() . ' WHERE a.company_id = :cid AND a.id = :id LIMIT 1'); $sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId, ':id' => $id]); return $sth->fetch(PDO::FETCH_ASSOC) ?: null; } /** Asset, month-by-month schedule (recorded + projected) and its GL entries. */ public function getDetail(int $id): array { $asset = $this->getById($id); if (!$asset) throw new Exception('Asset not found.'); $sth = $this->pdo->prepare( "SELECT d.period, d.amount, d.accumulated_after, d.book_value_after, d.run_id, COALESCE(r.run_number, '') AS run_number FROM td_asset_depreciation d LEFT JOIN td_asset_run r ON r.company_id = d.company_id AND r.id = d.run_id WHERE d.company_id = :cid AND d.asset_id = :id ORDER BY d.period ASC" ); $sth->execute([':cid' => $this->companyId, ':id' => $id]); $recorded = []; foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) { $recorded[$row['period']] = $row; } $cost = (float)$asset['cost']; $salvage = (float)$asset['salvage_value']; $life = (int)$asset['useful_life_months']; $disposal_period = $asset['disposal_date'] ? DepreciationCalculator::periodOf($asset['disposal_date']) : ''; $schedule = []; if ($asset['in_service_date'] && $life > 0 && $cost > $salvage) { foreach (DepreciationCalculator::schedule($cost, $salvage, $life, $asset['in_service_date']) as $row) { if (isset($recorded[$row['period']])) { $r = $recorded[$row['period']]; $row = [ 'period' => $row['period'], 'amount' => (float)$r['amount'], 'accumulated' => (float)$r['accumulated_after'], 'book_value' => (float)$r['book_value_after'], 'state' => 'recorded', 'run_id' => (int)$r['run_id'], 'run_number' => $r['run_number'], ]; } else { $stopped = $disposal_period !== '' && $row['period'] >= $disposal_period; $row['state'] = $stopped || (int)$asset['status'] === self::STATUS_VOID ? 'stopped' : 'projected'; } $schedule[] = $row; } } return [ 'asset' => $asset, 'schedule' => $schedule, 'monthly_amount' => $life > 0 ? DepreciationCalculator::monthlyAmount($cost, $salvage, $life) : 0, 'gl' => [ 'capitalization' => $this->glEntry('asset_capitalization', $id), 'disposal' => $this->glEntry('asset_disposal', $id), ], ]; } /** * One purchase invoice line, the part of it already capitalized, and the * account its purchase posting debited (the default clearing account). */ public function getInvoiceLine(int $invoice_id, int $item_id, int $exclude_asset_id = 0): array { $sth = $this->pdo->prepare( "SELECT i.id AS invoice_id, i.invoice_number, i.issued_date, i.contact_id, i.department_id, i.status AS invoice_status, i.formula_id, i.grand_total, i.tax, COALESCE(c.contact_name, '') AS contact_name, it.id AS item_id, it.product_sku, it.product_name, it.quantity, it.unit_price, it.total_price, COALESCE(NULLIF(p.purchase_account_code, ''), '') AS product_account_code FROM td_invoice i JOIN td_invoice_item it ON it.company_id = i.company_id AND it.invoice_id = i.id LEFT JOIN md_contact c ON c.company_id = i.company_id AND c.id = i.contact_id LEFT JOIN md_product p ON p.company_id = it.company_id AND p.sku = it.product_sku WHERE i.company_id = :cid AND i.id = :invoice_id AND it.id = :item_id AND i.doc_type = 'purchase_invoice' LIMIT 1" ); $sth->execute([':cid' => $this->companyId, ':invoice_id' => $invoice_id, ':item_id' => $item_id]); $line = $sth->fetch(PDO::FETCH_ASSOC); if (!$line) throw new Exception('Purchase invoice line not found.'); if (!in_array((int)$line['invoice_status'], [1, 2], true)) { throw new Exception('Only issued or paid purchase invoices can be turned into assets.'); } $line['line_amount'] = $this->postedLineAmount($invoice_id, (float)$line['total_price'], (float)$line['grand_total'], (float)$line['tax']); $sth = $this->pdo->prepare( "SELECT COALESCE(SUM(cost), 0) FROM td_asset WHERE company_id = :cid AND invoice_item_id = :item_id AND status <> 4 AND id <> :exclude" ); $sth->execute([':cid' => $this->companyId, ':item_id' => $item_id, ':exclude' => $exclude_asset_id]); $line['capitalized'] = round((float)$sth->fetchColumn(), 2); $line['remaining'] = round($line['line_amount'] - $line['capitalized'], 2); $line['clearing_account_code'] = $line['product_account_code'] !== '' ? $line['product_account_code'] : $this->formulaTotalAccount((int)$line['formula_id']); return $line; } /** Issued purchase invoice lines with how much of each has been capitalized. */ public function getCapitalizableLines(string $date_from = '', string $date_to = ''): array { $where = ['i.company_id = :cid', "i.doc_type = 'purchase_invoice'", 'i.status IN (1, 2)']; $params = [':cid' => $this->companyId, ':cid_s' => $this->companyId, ':cid_x' => $this->companyId]; if ($date_from = $this->parseDate($date_from)) { $where[] = 'i.issued_date >= :date_from'; $params[':date_from'] = $date_from; } if ($date_to = $this->parseDate($date_to)) { $where[] = 'i.issued_date <= :date_to'; $params[':date_to'] = $date_to; } $sth = $this->pdo->prepare( "SELECT i.id AS invoice_id, i.invoice_number, i.issued_date, i.status AS invoice_status, i.grand_total, i.tax, s.sum_total, COALESCE(c.contact_name, '') AS contact_name, it.id AS item_id, it.product_sku, it.product_name, it.quantity, it.total_price, COALESCE(x.capitalized, 0) AS capitalized, COALESCE(x.asset_count, 0) AS asset_count FROM td_invoice i JOIN td_invoice_item it ON it.company_id = i.company_id AND it.invoice_id = i.id JOIN (SELECT invoice_id, SUM(ABS(total_price)) AS sum_total FROM td_invoice_item WHERE company_id = :cid_s GROUP BY invoice_id) s ON s.invoice_id = i.id LEFT JOIN md_contact c ON c.company_id = i.company_id AND c.id = i.contact_id LEFT JOIN (SELECT invoice_item_id, SUM(cost) AS capitalized, COUNT(*) AS asset_count FROM td_asset WHERE company_id = :cid_x AND status <> 4 GROUP BY invoice_item_id) x ON x.invoice_item_id = it.id WHERE " . implode(' AND ', $where) . " ORDER BY i.issued_date DESC, i.id DESC, it.item_id ASC" ); $sth->execute($params); $rows = []; foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) { $sum = (float)$row['sum_total']; $row['line_amount'] = $sum > 0 ? round(((float)$row['grand_total'] - (float)$row['tax']) * abs((float)$row['total_price']) / $sum, 2) : 0.0; $row['capitalized'] = round((float)$row['capitalized'], 2); $row['remaining'] = round($row['line_amount'] - $row['capitalized'], 2); unset($row['grand_total'], $row['tax'], $row['sum_total']); $rows[] = $row; } return $rows; } public function getStats(): array { $sth = $this->pdo->prepare( "SELECT COUNT(*) AS total, COALESCE(SUM(a.status = 0), 0) AS draft, COALESCE(SUM(a.status = 1), 0) AS active, COALESCE(SUM(a.status = 2), 0) AS fully_depreciated, COALESCE(SUM(a.status = 3), 0) AS disposed, COALESCE(SUM(IF(a.status IN (1, 2), a.cost, 0)), 0) AS cost_in_use, COALESCE(SUM(IF(a.status IN (1, 2), COALESCE(d.accumulated, 0), 0)), 0) AS accumulated_in_use FROM td_asset a LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d ON d.asset_id = a.id WHERE a.company_id = :cid AND a.status <> 4" ); $sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId]); $stats = $sth->fetch(PDO::FETCH_ASSOC) ?: []; $stats['book_value_in_use'] = round((float)($stats['cost_in_use'] ?? 0) - (float)($stats['accumulated_in_use'] ?? 0), 2); $sth = $this->pdo->prepare( "SELECT c.id, c.category_code, c.category_name, c.asset_class, COUNT(a.id) AS asset_count, COALESCE(SUM(a.cost), 0) AS cost, COALESCE(SUM(COALESCE(d.accumulated, 0)), 0) AS accumulated FROM td_asset a JOIN md_asset_category c ON c.company_id = a.company_id AND c.id = a.category_id LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d ON d.asset_id = a.id WHERE a.company_id = :cid AND a.status IN (1, 2) GROUP BY c.id, c.category_code, c.category_name, c.asset_class ORDER BY c.category_code ASC" ); $sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId]); $stats['by_category'] = $sth->fetchAll(PDO::FETCH_ASSOC); return $stats; } // ── write ──────────────────────────────────────────────────────────────── public function save(array $data, int $user_id): int { $id = (int)($data['id'] ?? 0); $existing = $id ? $this->getById($id) : null; if ($id && !$existing) throw new Exception('Asset not found.'); if ($existing && in_array((int)$existing['status'], [self::STATUS_DISPOSED, self::STATUS_VOID], true)) { throw new Exception('Disposed or void assets cannot be edited.'); } $name = trim((string)($data['asset_name'] ?? '')); if ($name === '') throw new Exception('Asset name is required.'); $description = trim((string)($data['description'] ?? '')); $department_id = (int)($data['department_id'] ?? 0); // Once a month has been charged, the numbers behind the schedule are fixed. if ($existing && $this->hasDepreciation($id)) { $this->pdo->prepare( "UPDATE td_asset SET asset_name = :name, description = :desc, department_id = :dept, updated_at = NOW() WHERE company_id = :cid AND id = :id" )->execute([':name' => $name, ':desc' => $description, ':dept' => $department_id, ':cid' => $this->companyId, ':id' => $id]); return $id; } $category = (new AssetCategoryManager($this->pdo, $this->companyId))->getById((int)($data['category_id'] ?? 0)); if (!$category) throw new Exception('Select an asset category.'); $same_category = $existing && (int)$existing['category_id'] === (int)$category['id']; if ((int)$category['status'] !== 1 && !$same_category) throw new Exception('The selected category is inactive.'); $cost = round((float)($data['cost'] ?? 0), 2); $salvage = round((float)($data['salvage_value'] ?? 0), 2); $life = (int)($data['useful_life_months'] ?? 0); DepreciationCalculator::assertInputs($cost, $salvage, $life); $acquisition = $this->parseDate((string)($data['acquisition_date'] ?? '')); $in_service = $this->parseDate((string)($data['in_service_date'] ?? '')) ?: $acquisition; if ($acquisition === '') throw new Exception('Acquisition date is required.'); if ($in_service < $acquisition) throw new Exception('The in-service date cannot be before the acquisition date.'); $clearing = trim((string)($data['clearing_account_code'] ?? '')); if ($clearing !== '' && !AssetCategoryManager::isPostingAccount($this->pdo, $this->companyId, $clearing)) { throw new Exception("Clearing account {$clearing} is not an active posting account."); } $invoice_id = $existing ? (int)$existing['invoice_id'] : (int)($data['invoice_id'] ?? 0); $item_id = $existing ? (int)$existing['invoice_item_id'] : (int)($data['invoice_item_id'] ?? 0); $contact_id = $existing ? (int)$existing['contact_id'] : 0; $source = 'manual'; if ($item_id > 0) { $line = $this->getInvoiceLine($invoice_id, $item_id, $id); if ($cost > $line['remaining'] + 0.004) { throw new Exception('Cost is more than the part of this invoice line not yet capitalized (' . number_format($line['remaining'], 2) . ').'); } if ($clearing === '') { throw new Exception('Assets from a purchase invoice need the clearing account the invoice was posted to.'); } $contact_id = (int)$line['contact_id']; $source = 'purchase_invoice'; } $params = [ ':cid' => $this->companyId, ':name' => $name, ':category_id' => (int)$category['id'], ':class' => $category['asset_class'], ':desc' => $description, ':dept' => $department_id, ':acquisition' => $acquisition, ':in_service' => $in_service, ':cost' => $cost, ':salvage' => $salvage, ':life' => $life, ':clearing' => $clearing, ]; if ($existing) { $params[':id'] = $id; $this->pdo->prepare( "UPDATE td_asset SET asset_name = :name, category_id = :category_id, asset_class = :class, description = :desc, department_id = :dept, acquisition_date = :acquisition, in_service_date = :in_service, cost = :cost, salvage_value = :salvage, useful_life_months = :life, clearing_account_code = :clearing, updated_at = NOW() WHERE company_id = :cid AND id = :id" )->execute($params); return $id; } $params += [ ':number' => (new DocumentNumberManager($this->pdo, $this->companyId))->generate('asset'), ':source' => $source, ':invoice_id' => $invoice_id, ':item_id' => $item_id, ':contact_id' => $contact_id, ':user_id' => $user_id, ]; $this->pdo->prepare( "INSERT INTO td_asset (company_id, asset_number, asset_name, category_id, asset_class, description, source, invoice_id, invoice_item_id, contact_id, department_id, acquisition_date, in_service_date, cost, salvage_value, useful_life_months, clearing_account_code, status, created_by, created_at) VALUES (:cid, :number, :name, :category_id, :class, :desc, :source, :invoice_id, :item_id, :contact_id, :dept, :acquisition, :in_service, :cost, :salvage, :life, :clearing, 0, :user_id, NOW())" )->execute($params); return (int)$this->pdo->lastInsertId(); } public function activate(int $id): void { $asset = $this->requireAsset($id); if ((int)$asset['status'] !== self::STATUS_DRAFT) throw new Exception('Only draft assets can be activated.'); $category = (new AssetCategoryManager($this->pdo, $this->companyId))->getById((int)$asset['category_id']); if (!$category || (int)$category['status'] !== 1) throw new Exception('The asset category is missing or inactive.'); DepreciationCalculator::assertInputs((float)$asset['cost'], (float)$asset['salvage_value'], (int)$asset['useful_life_months']); $this->setStatus($id, self::STATUS_ACTIVE); } /** Drafts are removed outright (soft delete, like other documents). */ public function delete(int $id): void { $asset = $this->requireAsset($id); if ((int)$asset['status'] !== self::STATUS_DRAFT) throw new Exception('Only draft assets can be deleted. Void an active asset instead.'); $this->pdo->prepare( "UPDATE td_asset SET company_id = company_id * -1, updated_at = NOW() WHERE company_id = :cid AND id = :id" )->execute([':cid' => $this->companyId, ':id' => $id]); } /** Void an active asset recorded in error. Reverses its capitalization entry if posted. */ public function void(int $id, GlManager $gl): void { $asset = $this->requireAsset($id); if (!in_array((int)$asset['status'], [self::STATUS_ACTIVE, self::STATUS_FULLY], true)) { throw new Exception('Only active assets can be voided.'); } if ($this->hasDepreciation($id)) { throw new Exception('This asset has depreciation recorded. Void its depreciation runs first.'); } $gl->delete('asset_capitalization', $id); $this->setStatus($id, self::STATUS_VOID); } /** * Record a sale or write-off. Every month before the disposal month must * already be charged; the disposal month itself gets no charge. */ public function dispose(int $id, array $data): void { $asset = $this->requireAsset($id); if (!in_array((int)$asset['status'], [self::STATUS_ACTIVE, self::STATUS_FULLY], true)) { throw new Exception('Only active assets can be disposed of.'); } $date = $this->parseDate((string)($data['disposal_date'] ?? '')); if ($date === '') throw new Exception('Disposal date is required.'); if ($date < $asset['in_service_date']) throw new Exception('The disposal date cannot be before the in-service date.'); $sale_price = round((float)($data['sale_price'] ?? 0), 2); if ($sale_price < 0) throw new Exception('Sale price cannot be negative.'); $proceeds = trim((string)($data['proceeds_account_code'] ?? '')); if ($sale_price > 0 && $proceeds === '') throw new Exception('Select the account that received the sale proceeds.'); if ($proceeds !== '' && !AssetCategoryManager::isPostingAccount($this->pdo, $this->companyId, $proceeds)) { throw new Exception("Proceeds account {$proceeds} is not an active posting account."); } $disposal_period = DepreciationCalculator::periodOf($date); $sth = $this->pdo->prepare( "SELECT COUNT(*) FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id AND period >= :period" ); $sth->execute([':cid' => $this->companyId, ':id' => $id, ':period' => $disposal_period]); if ((int)$sth->fetchColumn() > 0) { throw new Exception("Depreciation is already recorded for {$disposal_period} or later. Void those runs before disposing of the asset on this date."); } $sth = $this->pdo->prepare("SELECT period FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id"); $sth->execute([':cid' => $this->companyId, ':id' => $id]); $recorded = array_flip($sth->fetchAll(PDO::FETCH_COLUMN)); $schedule = DepreciationCalculator::schedule( (float)$asset['cost'], (float)$asset['salvage_value'], (int)$asset['useful_life_months'], $asset['in_service_date'] ); foreach ($schedule as $row) { if ($row['period'] >= $disposal_period) break; if ($row['amount'] > 0 && !isset($recorded[$row['period']])) { $label = $asset['asset_class'] === 'intangible' ? 'amortization' : 'depreciation'; throw new Exception("Run {$label} through " . DepreciationCalculator::addMonths($disposal_period, -1) . " before disposing of this asset (first month not charged: {$row['period']})."); } } $this->pdo->prepare( "UPDATE td_asset SET status = :status, disposal_date = :date, sale_price = :price, proceeds_account_code = :proceeds, disposal_notes = :notes, updated_at = NOW() WHERE company_id = :cid AND id = :id" )->execute([ ':status' => self::STATUS_DISPOSED, ':date' => $date, ':price' => $sale_price, ':proceeds' => $proceeds, ':notes' => trim((string)($data['disposal_notes'] ?? '')), ':cid' => $this->companyId, ':id' => $id, ]); } /** Reverse a disposal (and its GL entry if posted). */ public function undoDisposal(int $id, GlManager $gl): void { $asset = $this->requireAsset($id); if ((int)$asset['status'] !== self::STATUS_DISPOSED) throw new Exception('This asset has not been disposed of.'); $gl->delete('asset_disposal', $id); $this->pdo->prepare( "UPDATE td_asset SET status = :status, disposal_date = NULL, sale_price = 0, proceeds_account_code = '', disposal_notes = '', updated_at = NOW() WHERE company_id = :cid AND id = :id" )->execute([':status' => self::STATUS_ACTIVE, ':cid' => $this->companyId, ':id' => $id]); $this->syncStatuses([$id]); } /** Set active / fully depreciated from what has been charged so far. */ public function syncStatuses(array $asset_ids): void { $ids = array_values(array_unique(array_filter(array_map('intval', $asset_ids)))); if (!$ids) return; $this->pdo->prepare( "UPDATE td_asset a LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d ON d.asset_id = a.id SET a.status = IF(COALESCE(d.accumulated, 0) >= ROUND(a.cost - a.salvage_value, 2) - 0.004, 2, 1), a.updated_at = NOW() WHERE a.company_id = :cid AND a.status IN (1, 2) AND a.id IN (" . implode(',', $ids) . ")" )->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId]); } // ── private ────────────────────────────────────────────────────────────── private function baseSelect(): string { return "SELECT a.*, COALESCE(c.category_code, '') AS category_code, COALESCE(c.category_name, '') AS category_name, COALESCE(ct.contact_name, '') AS contact_name, COALESCE(i.invoice_number, '') AS invoice_number, COALESCE(d.accumulated, 0) AS accumulated, COALESCE(d.last_period, '') AS last_period, ROUND(a.cost - COALESCE(d.accumulated, 0), 4) AS book_value FROM td_asset a LEFT JOIN md_asset_category c ON c.company_id = a.company_id AND c.id = a.category_id LEFT JOIN md_contact ct ON ct.company_id = a.company_id AND ct.id = a.contact_id LEFT JOIN td_invoice i ON i.company_id = a.company_id AND i.id = a.invoice_id LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated, MAX(period) AS last_period FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d ON d.asset_id = a.id"; } private function requireAsset(int $id): array { $sth = $this->pdo->prepare("SELECT * FROM td_asset WHERE company_id = :cid AND id = :id FOR UPDATE"); $sth->execute([':cid' => $this->companyId, ':id' => $id]); $asset = $sth->fetch(PDO::FETCH_ASSOC); if (!$asset) throw new Exception('Asset not found.'); return $asset; } private function setStatus(int $id, int $status): void { $this->pdo->prepare("UPDATE td_asset SET status = :status, updated_at = NOW() WHERE company_id = :cid AND id = :id") ->execute([':status' => $status, ':cid' => $this->companyId, ':id' => $id]); } private function hasDepreciation(int $id): bool { $sth = $this->pdo->prepare("SELECT COUNT(*) FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id"); $sth->execute([':cid' => $this->companyId, ':id' => $id]); return (int)$sth->fetchColumn() > 0; } private function glEntry(string $source_type, int $source_id): ?array { $sth = $this->pdo->prepare( "SELECT id, reference, journal_date, period, current_version, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS posted_at FROM td_gl WHERE company_id = :cid AND source_type = :type AND source_id = :id LIMIT 1" ); $sth->execute([':cid' => $this->companyId, ':type' => $source_type, ':id' => $source_id]); return $sth->fetch(PDO::FETCH_ASSOC) ?: null; } /** * The part of the invoice's pre-tax total the purchase posting put on this * line — PurchaseInvoicePosting splits (grand_total - tax) by line total. */ private function postedLineAmount(int $invoice_id, float $line_total, float $grand_total, float $tax): float { $sth = $this->pdo->prepare( "SELECT COALESCE(SUM(ABS(total_price)), 0) FROM td_invoice_item WHERE company_id = :cid AND invoice_id = :id" ); $sth->execute([':cid' => $this->companyId, ':id' => $invoice_id]); $sum = (float)$sth->fetchColumn(); if ($sum <= 0) return 0.0; return round((abs($grand_total) - abs($tax)) * abs($line_total) / $sum, 2); } private function formulaTotalAccount(int $formula_id): string { if ($formula_id <= 0) { $sth = $this->pdo->prepare( "SELECT id FROM md_account_formula WHERE company_id = :cid AND document_type = 'purchase_invoice' AND is_default = 1 AND status = 1 LIMIT 1" ); $sth->execute([':cid' => $this->companyId]); $formula_id = (int)$sth->fetchColumn(); } if ($formula_id <= 0) return ''; $sth = $this->pdo->prepare( "SELECT account_code FROM md_account_formula_item WHERE company_id = :cid AND formula_id = :fid AND amount_key = 'total' AND drcr = 'D' ORDER BY sort_order ASC LIMIT 1" ); $sth->execute([':cid' => $this->companyId, ':fid' => $formula_id]); return (string)($sth->fetchColumn() ?: ''); } private function parseDate(string $value): string { $value = trim($value); if ($value === '') return ''; if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $value, $m)) return "{$m[3]}-{$m[2]}-{$m[1]}"; if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $value)) return $value; throw new Exception("Invalid date '{$value}'."); } }