feat(insurance): bill an appointment with a chosen service kind and insurance

An appointment can now carry the insurance it is billed with: the service kind
(outpatient/inpatient) and the basic insurance. Confirming it no longer hands the
whole amount to the patient — the visit is split through BillingCalculator with the
coverage percent of that service kind, and the choice travels to the encounter and
the invoice built from it.

The enabled service kinds are a tenant-wide setting (all of that tenant's
insurances share it), so a tenant covering only one kind is never asked which one:
the panel resolves it the same way the server does.

- add tenant_service_category_settings + TenantServiceCategoryService, exposed on
  the existing insurance-pricing endpoint (service_categories,
  default_service_category); at least one kind must stay enabled
- add appointments.insurance_service_category / insurance_base_id with
  AppointmentInsuranceService validating them against the tenant's own settings
  and active contracts (basic only), accepted by PATCH and by confirm
- snapshot the kind on patient_sessions and invoices; the visit's coverage rule is
  resolved per kind (services keep using their own ServiceItem.service_category)
- lib/insuranceShares becomes the single client-side mirror of BillingCalculator,
  shared by the confirm modal, the appointment edit page and the session form
- surface the selection: confirm modal (with live shares), turns timeline chip,
  appointment edit page, patient record service card and invoice summary
- the session form shows the insurance block whenever the tenant has an active
  contract and prefills the patient's own insurance, so it can be changed
- fix: the confirm modal showed a zero visit price when the appointment had none —
  it now falls back to the tenant's free-visit price like the server
- fix: useServiceCategories read one level too shallow, so Persian labels never
  arrived and raw enum keys leaked into the contract summary
- fix: BlogsPage test asserted the public blogs endpoint after the page moved to
  the admin one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-25 17:50:14 +03:30
co-authored by Claude Opus 5
parent 58c6d9ac18
commit 1f58b1b9b3
47 changed files with 2693 additions and 86 deletions
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Tenant-wide switch for the service kinds (outpatient/inpatient) an insurance covers.
* No backfill: a missing row reads as enabled, so existing tenants keep both kinds.
*/
final class Version20260725131031 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add tenant_service_category_settings (per-tenant enabled insurance service kinds)';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE tenant_service_category_settings (id INT AUTO_INCREMENT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, service_category VARCHAR(30) NOT NULL, enabled TINYINT DEFAULT 1 NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX uniq_tenant_service_category_setting (entity_type, entity_id, service_category), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE tenant_service_category_settings');
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Insurance selection carried by an appointment: the service kind it is billed as
* (outpatient/inpatient) and the chosen basic insurance. Both nullable — an
* appointment without insurance keeps its previous behaviour.
*/
final class Version20260725131554 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add appointments.insurance_service_category and appointments.insurance_base_id';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE appointments ADD insurance_service_category VARCHAR(30) DEFAULT NULL, ADD insurance_base_id INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE appointments DROP insurance_service_category, DROP insurance_base_id');
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* The insurance service kind a visit was billed as, snapshotted on the encounter and
* on the invoice built from it.
*/
final class Version20260725132739 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add patient_sessions.insurance_service_category and invoices.service_category';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE patient_sessions ADD insurance_service_category VARCHAR(30) DEFAULT NULL');
$this->addSql('ALTER TABLE invoices ADD service_category VARCHAR(30) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE patient_sessions DROP insurance_service_category');
$this->addSql('ALTER TABLE invoices DROP service_category');
}
}