accounting workflows
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
<?php
|
||||
|
||||
class PaymentBillingManager {
|
||||
|
||||
private PDO $pdo;
|
||||
private int $company_id;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id) {
|
||||
$this->pdo = $pdo;
|
||||
$this->company_id = $company_id;
|
||||
}
|
||||
|
||||
private function generateBillingNumber(): string
|
||||
{
|
||||
$prefix = 'PB-' . date('Ymd') . '-';
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT billing_number FROM td_payment_billing
|
||||
WHERE company_id = :company_id
|
||||
AND billing_number LIKE :prefix
|
||||
ORDER BY billing_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':prefix' => $prefix . '%',
|
||||
]);
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function unavailableDocumentAmount(int $invoice_id): float
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COALESCE(billed.billed_amount, 0) + COALESCE(direct.paid_amount, 0)
|
||||
FROM (
|
||||
SELECT :company_id AS company_id, :invoice_id AS invoice_id
|
||||
) doc
|
||||
LEFT JOIN (
|
||||
SELECT ba.company_id, ba.invoice_id, SUM(ba.amount) AS billed_amount
|
||||
FROM td_payment_billing_item ba
|
||||
JOIN td_payment_billing b
|
||||
ON b.company_id = ba.company_id
|
||||
AND b.id = ba.billing_id
|
||||
WHERE ba.company_id = :company_id_billed
|
||||
AND ba.invoice_id = :invoice_id_billed
|
||||
AND b.status IN (1, 2, 3)
|
||||
GROUP BY ba.company_id, ba.invoice_id
|
||||
) billed
|
||||
ON billed.company_id = doc.company_id
|
||||
AND billed.invoice_id = doc.invoice_id
|
||||
LEFT JOIN (
|
||||
SELECT ra.company_id, ra.invoice_id, SUM(ra.amount) AS paid_amount
|
||||
FROM td_payment_item ra
|
||||
JOIN td_payment r
|
||||
ON r.company_id = ra.company_id
|
||||
AND r.id = ra.payment_id
|
||||
WHERE ra.company_id = :company_id_direct
|
||||
AND ra.invoice_id = :invoice_id_direct
|
||||
AND r.status = 1
|
||||
AND r.payment_billing_id = 0
|
||||
GROUP BY ra.company_id, ra.invoice_id
|
||||
) direct
|
||||
ON direct.company_id = doc.company_id
|
||||
AND direct.invoice_id = doc.invoice_id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':invoice_id' => $invoice_id,
|
||||
':company_id_billed' => $this->company_id,
|
||||
':invoice_id_billed' => $invoice_id,
|
||||
':company_id_direct' => $this->company_id,
|
||||
':invoice_id_direct' => $invoice_id,
|
||||
]);
|
||||
return (float)$sth->fetchColumn();
|
||||
}
|
||||
|
||||
public function getBillableDocuments(int $contact_id = 0): array
|
||||
{
|
||||
$where = [
|
||||
"i.company_id = :company_id",
|
||||
"i.doc_type IN ('purchase_invoice', 'supplier_credit_note')",
|
||||
"i.status = 1"
|
||||
];
|
||||
$params = [':company_id' => $this->company_id];
|
||||
|
||||
$where[] = 'i.contact_id = :contact_id';
|
||||
$params[':contact_id'] = $contact_id;
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
po.po_number AS order_number,
|
||||
COALESCE(payment.paid_amount, 0) AS paid_amount,
|
||||
COALESCE(billed.billed_amount, 0) AS billed_amount
|
||||
FROM td_invoice i
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id
|
||||
AND c.id = i.contact_id
|
||||
LEFT JOIN td_purchase_order po
|
||||
ON po.company_id = i.company_id
|
||||
AND po.id = i.source_id
|
||||
AND i.source = 'po'
|
||||
LEFT JOIN (
|
||||
SELECT ra.company_id, ra.invoice_id, SUM(ra.amount) AS paid_amount
|
||||
FROM td_payment_item ra
|
||||
JOIN td_payment r
|
||||
ON r.company_id = ra.company_id
|
||||
AND r.id = ra.payment_id
|
||||
WHERE r.status = 1
|
||||
AND r.payment_billing_id = 0
|
||||
GROUP BY ra.company_id, ra.invoice_id
|
||||
) payment
|
||||
ON payment.company_id = i.company_id
|
||||
AND payment.invoice_id = i.id
|
||||
LEFT JOIN (
|
||||
SELECT ba.company_id, ba.invoice_id, SUM(ba.amount) AS billed_amount
|
||||
FROM td_payment_billing_item ba
|
||||
JOIN td_payment_billing b
|
||||
ON b.company_id = ba.company_id
|
||||
AND b.id = ba.billing_id
|
||||
WHERE b.status IN (1, 2, 3)
|
||||
GROUP BY ba.company_id, ba.invoice_id
|
||||
) billed
|
||||
ON billed.company_id = i.company_id
|
||||
AND billed.invoice_id = i.id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY i.issued_date ASC, i.id ASC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
|
||||
$open = [];
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$total = abs((float)$row['grand_total']);
|
||||
$used = (float)$row['paid_amount'] + (float)$row['billed_amount'];
|
||||
$balance = max(0, $total - $used);
|
||||
if ($balance <= 0.0001) continue;
|
||||
$row['document_total'] = $total;
|
||||
$row['balance_amount'] = $balance;
|
||||
$open[] = $row;
|
||||
}
|
||||
|
||||
return $open;
|
||||
}
|
||||
|
||||
public function getBillingList(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT b.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
COALESCE(SUM(CASE WHEN r.status = 1 THEN r.amount ELSE 0 END), 0) AS paid_amount
|
||||
FROM td_payment_billing b
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = b.company_id
|
||||
AND c.id = b.contact_id
|
||||
LEFT JOIN td_payment r
|
||||
ON r.company_id = b.company_id
|
||||
AND r.payment_billing_id = b.id
|
||||
WHERE b.company_id = :company_id
|
||||
GROUP BY b.id
|
||||
ORDER BY b.id DESC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$row['balance_amount'] = max(0, (float)$row['amount'] - (float)$row['paid_amount']);
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function getOpenBillingList(int $contact_id = 0): array
|
||||
{
|
||||
$rows = $this->getBillingList();
|
||||
return array_values(array_filter($rows, function($row) use ($contact_id) {
|
||||
if ((int)$row['status'] === 4) return false;
|
||||
if ((int)$row['contact_id'] !== $contact_id) return false;
|
||||
return (float)$row['balance_amount'] > 0.0001;
|
||||
}));
|
||||
}
|
||||
|
||||
public function getBillingById(int $billing_id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT b.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
COALESCE(SUM(CASE WHEN r.status = 1 THEN r.amount ELSE 0 END), 0) AS paid_amount
|
||||
FROM td_payment_billing b
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = b.company_id
|
||||
AND c.id = b.contact_id
|
||||
LEFT JOIN td_payment r
|
||||
ON r.company_id = b.company_id
|
||||
AND r.payment_billing_id = b.id
|
||||
WHERE b.company_id = :company_id
|
||||
AND b.id = :id
|
||||
GROUP BY b.id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $billing_id]);
|
||||
$billing = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$billing) throw new Exception("Payment billing not found.");
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.*,
|
||||
i.invoice_number,
|
||||
i.order_id,
|
||||
i.department_id,
|
||||
i.grand_total,
|
||||
i.status AS invoice_status,
|
||||
COALESCE(SUM(CASE WHEN r.status = 1 THEN ra.amount ELSE 0 END), 0) AS paid_line_amount,
|
||||
po.po_number AS order_number
|
||||
FROM td_payment_billing_item a
|
||||
JOIN td_invoice i
|
||||
ON i.company_id = a.company_id
|
||||
AND i.id = a.invoice_id
|
||||
LEFT JOIN td_payment_item ra
|
||||
ON ra.company_id = a.company_id
|
||||
AND ra.invoice_id = a.invoice_id
|
||||
LEFT JOIN td_payment r
|
||||
ON r.company_id = ra.company_id
|
||||
AND r.id = ra.payment_id
|
||||
AND r.payment_billing_id = a.billing_id
|
||||
LEFT JOIN td_purchase_order po
|
||||
ON po.company_id = i.company_id
|
||||
AND po.id = i.source_id
|
||||
AND i.source = 'po'
|
||||
WHERE a.company_id = :company_id
|
||||
AND a.billing_id = :billing_id
|
||||
GROUP BY a.id
|
||||
ORDER BY a.id ASC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':billing_id' => $billing_id]);
|
||||
$allocations = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($allocations as &$line) {
|
||||
$line['balance_amount'] = max(0, (float)$line['amount'] - (float)$line['paid_line_amount']);
|
||||
}
|
||||
$billing['allocations'] = $allocations;
|
||||
$billing['balance_amount'] = max(0, (float)$billing['amount'] - (float)$billing['paid_amount']);
|
||||
|
||||
return $billing;
|
||||
}
|
||||
|
||||
public function refreshBillingStatus(int $billing_id): void
|
||||
{
|
||||
$billing = $this->getBillingById($billing_id);
|
||||
if ((int)$billing['status'] === 4) return;
|
||||
if ((float)$billing['balance_amount'] <= 0.0001) {
|
||||
$new_status = 2;
|
||||
} elseif ((float)$billing['paid_amount'] > 0.0001) {
|
||||
$new_status = 3;
|
||||
} else {
|
||||
$new_status = 1;
|
||||
}
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_payment_billing
|
||||
SET status = :status
|
||||
WHERE company_id = :company_id
|
||||
AND id = :id"
|
||||
)->execute([
|
||||
':status' => $new_status,
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $billing_id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function createBilling(array $data, array $logging): int
|
||||
{
|
||||
$contact_id = (int)($data['contact_id'] ?? 0);
|
||||
$billing_date = $data['billing_date'] ?? date('Y-m-d');
|
||||
$notes = trim((string)($data['notes'] ?? ''));
|
||||
$allocations = $data['allocations'] ?? [];
|
||||
|
||||
if (is_string($allocations)) {
|
||||
$allocations = json_decode($allocations, true) ?: [];
|
||||
}
|
||||
|
||||
if ($contact_id <= 0) throw new Exception("Supplier is required.");
|
||||
if (!$allocations) throw new Exception("Payment billing requires at least one document.");
|
||||
|
||||
$validated = [];
|
||||
$purchase_invoice_total = 0.0;
|
||||
$credit_total = 0.0;
|
||||
|
||||
foreach ($allocations as $line) {
|
||||
$invoice_id = (int)($line['invoice_id'] ?? 0);
|
||||
$amount = (float)($line['amount'] ?? 0);
|
||||
if ($invoice_id <= 0 || $amount <= 0) continue;
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, doc_type, invoice_number, contact_id, department_id, grand_total, status
|
||||
FROM td_invoice
|
||||
WHERE company_id = :company_id AND id = :id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $invoice_id]);
|
||||
$doc = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$doc) throw new Exception("Document not found.");
|
||||
if ((int)$doc['contact_id'] !== $contact_id) {
|
||||
throw new Exception("All billing documents must belong to the selected contact.");
|
||||
}
|
||||
if (!in_array($doc['doc_type'], ['purchase_invoice', 'supplier_credit_note'], true)) {
|
||||
throw new Exception("Payment billing can only include purchase invoices and supplier credit notes.");
|
||||
}
|
||||
if ((int)$doc['status'] !== 1) {
|
||||
throw new Exception($doc['invoice_number'] . " is not open for billing.");
|
||||
}
|
||||
|
||||
$balance = abs((float)$doc['grand_total']) - $this->unavailableDocumentAmount($invoice_id);
|
||||
if ($amount - $balance > 0.0001) {
|
||||
throw new Exception($doc['invoice_number'] . " allocation exceeds remaining billable balance.");
|
||||
}
|
||||
|
||||
if ($doc['doc_type'] === 'supplier_credit_note') $credit_total += $amount;
|
||||
else $purchase_invoice_total += $amount;
|
||||
|
||||
$validated[] = [
|
||||
'invoice_id' => $invoice_id,
|
||||
'doc_type' => $doc['doc_type'],
|
||||
'department_id' => (int)($doc['department_id'] ?? 0),
|
||||
'amount' => round($amount, 4),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$validated) throw new Exception("Payment billing requires at least one valid document.");
|
||||
$net_amount = round($purchase_invoice_total - $credit_total, 4);
|
||||
if ($net_amount <= 0.0001) {
|
||||
throw new Exception("Payment billing amount must be greater than zero.");
|
||||
}
|
||||
|
||||
$log = [array_merge($logging, ['action' => 'create_payment_billing'])];
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_payment_billing
|
||||
(company_id, uuid, billing_number, contact_id, billing_date,
|
||||
amount, status, notes, `log`, created_at)
|
||||
VALUES
|
||||
(:company_id, :uuid, :billing_number, :contact_id, :billing_date,
|
||||
:amount, 1, :notes, :log, :created_at)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':billing_number' => $this->generateBillingNumber(),
|
||||
':contact_id' => $contact_id,
|
||||
':billing_date' => $billing_date,
|
||||
':amount' => $net_amount,
|
||||
':notes' => $notes,
|
||||
':log' => json_encode($log),
|
||||
':created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$billing_id = (int)$this->pdo->lastInsertId();
|
||||
|
||||
foreach ($validated as $line) {
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_payment_billing_item
|
||||
(company_id, billing_id, invoice_id, doc_type, amount)
|
||||
VALUES
|
||||
(:company_id, :billing_id, :invoice_id, :doc_type, :amount)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':billing_id' => $billing_id,
|
||||
':invoice_id' => $line['invoice_id'],
|
||||
':doc_type' => $line['doc_type'],
|
||||
':amount' => $line['amount'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $billing_id;
|
||||
}
|
||||
|
||||
public function voidBilling(int $billing_id, array $logging): void
|
||||
{
|
||||
$billing = $this->getBillingById($billing_id);
|
||||
if ((int)$billing['status'] === 4) throw new Exception("Payment billing is already void.");
|
||||
if ((float)$billing['paid_amount'] > 0.0001) {
|
||||
throw new Exception("Cannot void a payment billing with posted payments.");
|
||||
}
|
||||
|
||||
$log = json_decode($billing['log'] ?: '[]', true);
|
||||
if (!is_array($log)) $log = [];
|
||||
$log[] = array_merge($logging, ['action' => 'void_payment_billing']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_payment_billing
|
||||
SET status = 4, `log` = :log
|
||||
WHERE company_id = :company_id
|
||||
AND id = :id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $billing_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
?>
|
||||
Reference in New Issue
Block a user