148 Commits

Author SHA1 Message Date
Darko Gjorgjijoski
5c11147e95 feat(settings): allow Danger Zone for any owner regardless of company count
Removes three layered gates that kept the Danger Zone completely hidden unless the current user had more than one company:

1. SettingsLayoutView's showDangerZone computed no longer checks companies.length > 1 — just is_owner. 2. DangerZoneView drops the v-if that wrapped the delete button with the same check. 3. Admin\\CompaniesController::destroy() drops the companies_count <= 1 early-return that was enforcing the rule server-side (translation key You_cannot_delete_all_companies was inline in the controller, not in lang files or tests, so nothing else needs cleanup).

The reasoning behind the old gate was that a user with zero companies would be stranded. That's a misread of how the app degrades: /admin/no-company already exists as a graceful fallback view, and the user can create a fresh company from there to recover. Hiding the entire delete flow just to avoid that fallback UX was overkill — the name-confirmation modal already prevents accidental deletion.
2026-04-11 08:00:00 +02:00
Darko Gjorgjijoski
31a2a66127 refactor(modules): move Modules into Company Settings as Module Configuration
The per-company Modules management page moves off its own top-level sidebar slot (which sat in the Admin group alongside Members/Reports/Settings) and into a new Module Configuration entry inside Company Settings, alongside Tax Types, Payment Modes, Mail Configuration, etc. That's where every other 'configure how the company behaves' surface lives — the Modules page is a configuration surface, not a primary working area.

The label is deliberately 'Module Configuration' rather than 'Module Settings' because the latter collides with the existing per-module ModuleSettingsModal concept (the modal that opens when a user clicks an installed module's gear icon). Keeping the two names distinct means 'Module Configuration' unambiguously refers to the list of installed modules, and 'Module Settings' continues to mean the per-module schema form.

CompanyModulesIndexView is stripped of its standalone BasePage / BasePageHeader / BaseBreadcrumb wrappers — as a child of SettingsLayoutView it would have rendered a double header — and re-wrapped in BaseSettingCard, matching TaxTypesView and every other settings-child view. The module grid tightens from lg:grid-cols-2 xl:grid-cols-3 down to lg:grid-cols-2 since the settings sidebar eats 240px of horizontal real estate.

Routes consolidate: features/company/modules/routes.ts is deleted; the new settings.modules child route lives inside the settings routes file directly, alongside the rest. Top-level redirects are kept for the legacy /admin/modules and /admin/modules/:slug/settings URLs so existing bookmarks still resolve. ModuleRoutesConfigTest is re-pointed at settings/routes.ts and asserts the settings.modules route is owner-only.

Module-contributed sidebar entries (those registered via Registry::registerMenu()) are NOT moved. Modules that want top-level navigation visibility keep it; only the meta management page moves. This mirrors WordPress/Discourse conventions where plugin pages stay in the main navigation but the 'Plugins' admin screen itself lives under Settings.
2026-04-11 06:30:00 +02:00
Darko Gjorgjijoski
e44657bf7e feat(exchange-rate): make providers extendible via module Registry
Exchange rate providers are now pluggable via the module Registry. The four built-in drivers (currency_converter, currency_freak, currency_layer, open_exchange_rate) move from a static config array into App\\Providers\\DriverRegistryProvider, which calls Registry::registerExchangeRateDriver() for each during app boot with metadata the frontend needs: label (i18n key), website (help-text URL), and config_fields (schema for driver-specific driver_config JSON).

The Currency Converter's server-type selector and dedicated URL field — previously hardcoded in ExchangeRateProviderModal.vue — are now just another config_fields entry with a visible_when rule that shows the URL input only when type=DEDICATED. Any module that wants to ship a custom driver gets the same treatment for free: declare config_fields in the registration, and the host app's modal renders them automatically.

ExchangeRateDriverFactory::make() falls back to Registry::driverMeta() when a name isn't in the local built-in map, and availableDrivers() merges both sources. ConfigController handles the exchange_rate_drivers key specially by mapping Registry::allDrivers('exchange_rate') to enriched option objects, so the config-file route still works for every other key. The static exchange_rate_drivers + currency_converter_servers arrays in config/invoiceshelf.php are deleted.

Unit tests cover the new Registry::register/flushDrivers, the factory merging built-ins with Registry-contributed drivers, and the factory rejecting unknown names. A feature test exercises the end-to-end /api/v1/config?key=exchange_rate_drivers response shape.

NOTE: this commit depends on invoiceshelf/modules package commit e44d951 which adds the Registry driver API. The package needs to be released and pinned in composer.json before a fresh composer install on this commit will work.
2026-04-11 04:00:00 +02:00
Darko Gjorgjijoski
112cc56922 chore(infra): default mail driver to sendmail and expose Vue runtime
Mail DEFAULT_DRIVER changes from smtp to sendmail; DRIVER_ORDER is reshuffled so sendmail is the head of the list on fresh installs. This matches what most self-hosted installs already have working out of the box — SMTP requires provider credentials the typical user doesn't have set up yet. The mail config description is rewritten to drop the 'Laravel' framework reference and to explicitly tell unsure users to leave it on sendmail.

SiteApi::get() now catches GuzzleException (the broader interface) and returns null on network failure instead of bubbling the exception object — callers were treating a non-array return as 'marketplace unavailable' anyway, so null is the correct shape.

main.ts exposes the Vue runtime on window.__invoiceshelf_vue so module JS (compiled against the host's Vue install) can call createApp / defineComponent without re-bundling Vue. invoiceshelf.css adds Tailwind source globs for Modules/**/*.{js,ts,vue,blade.php} so module-contributed classes are picked up by the host CSS pipeline.

Installation wizard PreferencesView was already in the tree waiting for the API field rename (date_formats, time_zones, fiscal_years, languages) that landed in setting.service.ts; this commit catches both sides up together.
2026-04-11 02:00:00 +02:00
Darko Gjorgjijoski
7885bf9d11 feat(menu): priority-sorted menu groups, user-menu items, sidebar appearance toggle
Every main_menu entry moves from numeric group (1/2/3) to string-based group + group_label + priority. Groups now carry their own i18n label and child entries are sorted by an explicit priority field instead of config-array order, so module-contributed menu items can slot into any existing group at any position.

BootstrapController merges module-registered menu items into main_menu (previously they lived in a separate module_menu response key) and introduces a user_menu response key for items modules want to place in the avatar dropdown. The global store follows suit: moduleMenu becomes userMenu, menuGroups is a computed that sorts by priority, and hasActiveModules drops out.

New admin Appearance setting page with a single toggle for whether sidebar group labels render — so instances that prefer a compact sidebar can hide the Documents/Administration/Modules headings without losing the grouping itself. CompanyLayout watches route meta and re-bootstraps when the admin-mode flag flips so the sidebar repaints with the right menu on navigation across the admin boundary.

Test suites updated: module menu merging is asserted against main_menu (name: 'module-{slug}') rather than the old module_menu response; HelloWorldIntegrationTest verifies the schema translation path; CompanyModulesIndexTest covers the display_name attachment.
2026-04-11 00:30:00 +02:00
Darko Gjorgjijoski
345bfde306 feat(modules): translated display names and inline settings modal
CompanyModulesController attaches a translated display_name to each module before returning the list. ModuleSettingsController gains a translateSchema() helper that resolves section titles and field labels against the host app's i18n store before sending the schema to the frontend, so module authors can keep their 'my_module::settings.field' keys and users still see localized strings.

Per-module settings now open in an inline ModuleSettingsModal rather than routing to a standalone page. The modal reuses BaseSchemaForm for rendering, so the whole interaction takes place in-context next to the module card the user clicked — no navigation, no loss of place.

CompanyModuleCard displays the translated display_name instead of the raw slug and emits open-settings with the module payload; the parent view hands that to the modal store.
2026-04-10 21:30:00 +02:00
Darko Gjorgjijoski
3d79fe1abc feat(modules): redesign admin marketplace cards and detail view
ModuleCard moves badges to the top-right, shows a cover placeholder when art is missing, and drops the rating/pricing chrome that was never populated by the marketplace. ModuleDetailView splits into a hero row (cover + module info, two-thirds width) plus a sticky action card on the right (one-third) so install/update/purchase buttons stay visible when scrolling long descriptions.

ModuleIndexView promotes the marketplace API token form to a persistent card at the top of the page and adds an authenticated/premium status pill so super-admins can see whether the current token unlocks premium listings. The tabs and empty state were reorganized so 'installed' and 'marketplace' feel like peers.

The admin modules store tracks marketplace auth status, adds checkApiToken() and setApiToken() methods, and unifies the install-request shape into ModuleInstallPayload so both the free and paid install buttons route through the same code path.
2026-04-10 19:00:00 +02:00
Darko Gjorgjijoski
23d1476870 refactor(modules): marketplace install flow with checksum validation
Rewires module installation to use slug + version + checksum_sha256 instead of the opaque module identifier. ModuleInstaller splits marketplace token handling out of install() into helpers, adopts structured error responses, and validates the downloaded archive's SHA-256 against the marketplace manifest before unpacking.

ModuleResource is simplified to accept an already-loaded installed-module instance rather than fetching it from state, exposes access_tier and checksum fields, and drops the auto-disable-on-unpurchased side effect that was bleeding write logic into a read resource. UnzipUpdateRequest accepts a nullable module with a conditional module_name field so the same endpoint serves both app and module updates.

ModulesPolicy::manageModules now short-circuits for super-admins so administration flows (token validation, store state) are not blocked on a company-scoped ability. Two new feature tests cover both the authorization bypass and ModuleResource serialization.
2026-04-10 17:30:00 +02:00
Darko Gjorgjijoski
2af31d0e5f Rename local_public disk back to public and store avatars/logos on public disk
The public disk was accidentally removed during the Laravel 11 upgrade
and re-added as local_public in the FileDisk refactor. Restoring the
standard Laravel name avoids breaking Spatie MediaLibrary expectations
and simplifies the v2-to-v3 upgrade path.

User avatars and company logos now explicitly use the public disk via
registerMediaCollections(), keeping them web-accessible while the default
media disk remains private for sensitive documents like PDFs and receipts.

The v3 upgrade migration renames the system disk entry and any alpha media
records from local_public back to public.
2026-04-10 15:56:31 +02:00
Darko Gjorgjijoski
42ce99eeba Show common currencies first in dropdowns and default to USD in install wizard
Currency dropdowns now display the most-traded currencies (USD, EUR, GBP,
JPY, CAD, AUD, CHF, CNY, INR, BRL) at the top, followed by the rest
alphabetically. The install wizard defaults to USD instead of EUR and
formats currency names as "USD - US Dollar" for consistency with the
rest of the app.
2026-04-10 15:55:46 +02:00
Darko Gjorgjijoski
9174254165 Refactor install wizard and mail configuration 2026-04-09 10:06:27 +02:00
Darko Gjorgjijoski
1d2cca5837 feat(marketplace): make base_url env-configurable for local testing
The marketplace client (App\Services\Module\ModuleInstaller) and the updater
(App\Services\Update\Updater) both build their Guzzle base URI from
config('invoiceshelf.base_url') via the App\Traits\SiteApi::getRemote()
helper. The value was hardcoded to 'https://invoiceshelf.com', which made
local development against a self-hosted invoiceshelf/website checkout
impossible without editing the config file by hand.

Wrap it in env('INVOICESHELF_BASE_URL', 'https://invoiceshelf.com') so a
.env entry can repoint both the marketplace and the updater at any host —
e.g. http://invoiceshelf-website.test, http://localhost:8000, or a staging
deployment. The default fallback stays https://invoiceshelf.com so
production behavior is unchanged.

Document the new variable in .env.example, commented out so it has no
effect unless explicitly enabled.

The marketplace and updater are tied to the same base URL by design — a
local dev instance pointed at a local marketplace probably also wants to
test against the local release-API, so they should move together.
2026-04-09 00:30:59 +02:00
Darko Gjorgjijoski
93b04a0c2a test(modules): integration tests for company surfaces and stub generator
End-to-end coverage for the new module APIs and the custom module:make
stubs shipped from invoiceshelf/modules. Each test file is hermetic — uses
\InvoiceShelf\Modules\Registry::flush() in setup/teardown to prevent
cross-test contamination, and ModuleMakeStubTest cleans up generated test
artifacts (the throwaway scaffold directory and the storage statuses entry).

- CompanyModulesIndexTest: 4 tests covering only-enabled-modules filter,
  has_settings flag computed against the real Registry, menu inclusion, and
  the empty-state response.

- ModuleSettingsControllerTest: 7 tests covering 404 for unregistered slug,
  show schema + defaults round-trip, persistence with the
  module.{slug}.{key} prefix, missing-required-field rejection, unknown-key
  silent-drop, update 404, and per-company isolation (the load-bearing
  multi-tenancy guarantee).

- BootstrapModuleMenuTest: 3 tests covering Registry-driven module_menu
  population on the company-context bootstrap branch, the empty default
  when nothing is registered, and the absence of module_menu on the
  super-admin-mode branch.

- ModuleMakeStubTest: 3 tests that actually run
  Artisan::call('module:make', ['name' => ['ScaffoldProbe']]) against a
  throwaway module name and assert the generated ServiceProvider contains
  use InvoiceShelf\Modules\Registry, the generated composer.json requires
  invoiceshelf/modules: ^3.0, and starter lang/en/{menu,settings}.php exist.
  Validates that the custom stubs shipped from the package are picked up
  via Stub::setBasePath().
2026-04-09 00:30:24 +02:00
Darko Gjorgjijoski
7743c2e126 feat(modules): dynamic sidebar group rendering active modules
The sidebar gains a new section that lists each currently-activated module
as a direct shortcut to its settings page. This is the always-visible
companion to the company-context Active Modules index — both surface the
same set of modules, but the index is the catalog landing page and the
sidebar group is the per-module quick access.

- BootstrapController returns module_menu populated from
  \InvoiceShelf\Modules\Registry::allMenu(), but only on the company-context
  branch — not on the super-admin branch (lines 53-69), since super admins
  don't see the dynamic group. Because nwidart only boots service providers
  for currently-activated modules, the registry naturally contains only
  active modules at request time, no extra filtering needed.

- bootstrap.service.ts BootstrapResponse type extended with
  module_menu?: ModuleMenuItem[]; new ModuleMenuItem interface
  (title/link/icon) — shaped distinctly from MenuItem because module entries
  use namespaced i18n keys and don't carry group/ability metadata.

- global.store.ts exposes a moduleMenu ref + a hasActiveModules computed.

- SiteSidebar.vue appends a new "Modules" section after the existing
  menuGroups output, in both the mobile (Dialog) and desktop branches. The
  section is hidden when hasActiveModules is false. Uses the
  modules.sidebar.section_title i18n key added in the previous commit.
2026-04-09 00:29:56 +02:00
Darko Gjorgjijoski
e6eeacb6d4 feat(modules): company-context module surfaces and schema-driven settings
Adds the read-only company "Active Modules" index page (lists every
instance-activated module with a Settings shortcut) and the schema-driven
settings framework (generic BaseSchemaForm.vue renderer + per-company
persistence in CompanySetting). Bundled because they share the same
routes/api.php edit and the index page's Settings button targets the
settings page.

Backend:

- CompanyModulesController::index() returns every Module::enabled = true row
  with a kebab-case slug (via Str::kebab()) and a has_settings flag computed
  from \InvoiceShelf\Modules\Registry::settingsFor(). nwidart stores module
  names in PascalCase ("HelloWorld") but URLs and registry keys use kebab
  ("hello-world") — the controller normalizes so module authors can call
  Registry::registerSettings('hello-world') naturally without thinking
  about the storage format.

- ModuleSettingsController::show(\$slug) returns the registered Schema +
  per-company values from CompanySetting (defaults flow through when nothing
  has been saved yet). update(\$slug) builds Laravel validator rules from
  the Schema's per-field rules arrays — with type-rule fallbacks for
  switch -> boolean, number -> numeric, multiselect -> array — silently
  drops unknown keys, and persists via CompanySetting::setSettings() under
  the module.{slug}.{key} prefix. Activation is instance-global, but
  settings are per-company: two companies on the same instance can
  configure the same activated module differently.

- routes/api.php mounts GET /api/v1/company-modules at the root of the
  company API group and GET/PUT /api/v1/modules/{slug}/settings inside the
  existing modules prefix.

Frontend:

- BaseSchemaForm.vue is the central new component — a generic schema-driven
  form renderer that maps schema fields to BaseInput / BaseTextarea /
  BaseSwitch / BaseMultiselect by type, and builds Vuelidate rules
  dynamically from each field's rules array (supports required, email, url,
  numeric, min:N, max:N). New fields are added by extending the type ->
  component map.

- CompanyModulesIndexView.vue fetches /company-modules and renders a card
  grid (with empty/loading states); CompanyModuleCard.vue is the per-row
  component with the Settings button. ModuleSettingsView.vue fetches
  /modules/{slug}/settings, hands {schema, values} to BaseSchemaForm, and
  posts back on submit.

- Company-context routes.ts is rebuilt after the previous commit relocated
  the marketplace browser away. It now declares modules.index +
  modules.settings, both gated by manage-module ability.

- New api/services/{companyModules,moduleSettings}.service.ts thin clients.

- lang/en.json adds modules.index.{description,empty_title,empty_description},
  modules.settings.{title,open,saved,not_found,none}, and
  modules.sidebar.section_title. The sidebar key is added here even though
  the dynamic sidebar rendering lands in the next commit — keeping all i18n
  additions in one file edit avoids hunk-splitting lang/en.json.
2026-04-09 00:29:36 +02:00
Darko Gjorgjijoski
84725b2dfa feat(modules): relocate marketplace browser to super-admin context
The module marketplace browser UI (ModuleIndexView, ModuleDetailView,
ModuleCard, the four-step installer store) was filed under
features/company/modules/ only by historical accident — it's authorized via
the manage modules ability (super-admin-only) and conceptually belongs in the
admin context, not the company context.

- Move features/company/modules/{store.ts, views/ModuleIndexView.vue,
  views/ModuleDetailView.vue, components/ModuleCard.vue} to
  features/admin/modules/.
- Update hardcoded /admin/modules/... paths in the moved files to
  /admin/administration/modules/... so the breadcrumbs and ModuleCard
  navigation target the new admin-context routes.
- Tighten the four-step installer's silent catch {} blocks in the moved
  store.ts: errors were being swallowed, now they dispatch through the
  global notification store instead.
- New features/admin/modules/routes.ts declares admin.modules.index +
  admin.modules.view as children of /admin/administration with
  meta.isSuperAdmin: true.
- features/admin/{index,routes}.ts re-export and mount the relocated routes.
- config/invoiceshelf.php gains a new AdminModules entry in admin_menu
  pointing at /admin/administration/modules with super_admin_only: true.
- The dev-gated navigation.modules entry in main_menu is replaced (not
  deleted) with a non-gated entry pointing at the new company-context
  Active Modules index page that lands in the next commit. The
  ability is set to manage modules so non-owners can't see it.

The new company-context Active Modules index, schema-driven settings page,
and dynamic sidebar group are introduced in subsequent commits.
2026-04-09 00:28:59 +02:00
Darko Gjorgjijoski
b2b7a07e0c refactor(modules): migrate asset registry from app/Services to invoiceshelf/modules package
The vestigial App\Services\Module\Module static class — with its unused
\$scripts / \$styles / \$settings registries — never had any of its helpers
wired up. The new InvoiceShelf\Modules\Registry shipped from the
invoiceshelf/modules package supersedes it cleanly: same static-array surface
(\$menu, \$settings, \$scripts, \$styles), but lives outside the host app so
third-party modules can depend on it without importing v3-app internals.

Three consumers in the host app are migrated to the new namespace:

- ScriptController and StyleController (the HTTP endpoints that serve
  module-registered JS/CSS assets at /modules/scripts/{name} and
  /modules/styles/{name}) now look up paths via Registry::scriptFor() and
  Registry::styleFor() instead of Arr::get(ModuleFacade::all*(), \$name).
  Also tightens type hints — Request import + Response return type.

- resources/views/app.blade.php iterates Registry::allStyles() /
  Registry::allScripts() to inject module-supplied <link>/<script> tags into
  the main layout. Same Akaunting-style asset injection mechanism, just
  reading from the new namespace.

Both Module and ModuleFacade are deleted — they had no remaining callers
after this migration.
2026-04-09 00:27:44 +02:00
Darko Gjorgjijoski
61e1efd81b chore(deps): upgrade nwidart/laravel-modules to v13 via invoiceshelf/modules ^3.0
Pulls invoiceshelf/modules ^3.0 from packagist — a thin extension package on
top of current upstream nwidart/laravel-modules ^13.0 — replacing the stale
2021-era invoiceshelf/modules ^1.0 fork that bundled its own copy of nwidart.

The host app's autoloader now resolves Nwidart\Modules\* from
vendor/nwidart/laravel-modules and InvoiceShelf\Modules\* from
vendor/invoiceshelf/modules. Existing imports of Nwidart\Modules\Facades\Module
keep working unchanged.

config/modules.php is republished from upstream v13 with two
InvoiceShelf-specific overrides:

- activators.file.statuses-file kept at storage/app/modules_statuses.json so
  existing installations don't lose track of which modules are enabled when
  the config is republished (upstream v13 defaults to base_path()).
- New lang/menu and lang/settings entries in stubs.files / stubs.replacements
  that pair with the custom module:make stubs shipped from the package.

Wires wikimedia/composer-merge-plugin (a transitive dependency of nwidart) so
each module's nested composer.json autoload mapping is merged into the host
autoloader at composer dump-autoload time. This is what makes a module
generated via php artisan module:make MyModule actually loadable. The plugin
is added to allow-plugins and configured via extra.merge-plugin.include.

Drops the stale Modules\\: Modules/ PSR-4 fallback root from autoload — it
didn't match nwidart's app/-prefixed module layout and was always broken for
generated modules.
2026-04-09 00:27:16 +02:00
Darko Gjorgjijoski
1fb5886d06 Sanitize PDF address fields against SSRF in getFormattedString chokepoint
Closes the residual surface from the three published SSRF advisories (GHSA-pc5v-8xwc-v9xq, GHSA-38hf-fq8x-q49r, GHSA-q9wx-ggwq-mcgh / CVE-2026-34365 to 34367) that the original 2.2.0 fix only covered for the Notes field. The same blade templates render company/billing/shipping address fields with {!! !!} via Invoice/Estimate/Payment::getCompanyAddress(), getCustomerBillingAddress(), getCustomerShippingAddress() — and those flow through GeneratesPdfTrait::getFormattedString() which did not call PdfHtmlSanitizer.

Customer-controlled fields (name, street, phone, custom-field values) are substituted into address templates via getFieldsArray() without HTML-escaping, so a malicious customer name like "Acme <img src='http://attacker/probe'>" reaches Dompdf as raw HTML through the address path. Today this is blocked only by the secondary defense of dompdf's enable_remote=false; if a self-hoster sets DOMPDF_ENABLE_REMOTE=true for legitimate remote logos, the address surface immediately re-opens.

Move PdfHtmlSanitizer::sanitize() into the chokepoint at GeneratesPdfTrait::getFormattedString() so all four sinks — notes plus the three address fields, on all three models — get the same treatment via a single call site. v3.0's models (Invoice, Estimate, Payment) already had the simpler getNotes() shape (no per-method PdfHtmlSanitizer wrapper), so the trait edit alone is sufficient — no model edits required on this branch. Verified getFormattedString() is only called from PDF code paths (no email body callers, which use strtr() directly).

This is the v3.0 counterpart to master's f387e751. Re-implemented directly on v3.0 instead of cherry-picked because the import-block divergence from the larger v3.0 refactor produced four merge conflicts that were noisier than just porting the chokepoint change manually.

Extends tests/Unit/PdfHtmlSanitizerTest.php with three new cases covering the address-template scenario, iframe/link tag stripping, and on* event handler removal. All 8 tests pass via vendor/bin/pest tests/Unit/PdfHtmlSanitizerTest.php.
2026-04-07 20:36:05 +02:00
Darko Gjorgjijoski
bd756f2365 Rewrite README with v3.0 roadmap
Polishes the intro wording, removes the Mobile Apps section (the source repo is referenced from invoiceshelf.com instead) and the Credits section, and rebuilds the Roadmap as a single flat checklist that mixes shipped items with in-flight v3.0 work.

Shipped v3.0 items added: decoupled system settings from company settings, proper multi-tenancy system, company member invitations with custom roles, dark mode, full TypeScript refactor of the frontend, improved backend architecture, security hardening.

In-flight v3.0 items (bold + (v3.0) marker so they jump out as the active milestone): reworked installation wizard, Module Directory, rewritten Payments module. The two longer-term items (Stripe integration, improved template system) stay at the bottom unchanged.
2026-04-07 20:14:02 +02:00
Darko Gjorgjijoski
999ff3e977 Auto-update invoice due date when invoice date changes
Port of master's 241ec092 (Feat: Automatically set due date when invoice date is changed). The original commit was a JavaScript Options-API watcher in the deleted v1 InvoiceCreate.vue; v3.0's equivalent is a Composition-API TypeScript view at resources/scripts/features/company/invoices/views/InvoiceCreateView.vue, so this is a re-implementation rather than a cherry-pick.

Behaviour: if the company setting invoice_set_due_date_automatically is 'YES', a watcher on invoiceStore.newInvoice.invoice_date recomputes the due date as invoice_date + invoice_due_date_days whenever the invoice date changes. A second watcher on due_date tracks whether the user has manually edited it; if the manual value is still valid (>= the new invoice date) it is left alone, otherwise the auto value takes over. An isAutoUpdatingDueDate guard avoids a feedback loop when the watcher writes back to the store.

Uses moment for the date math, matching the original master commit and several other v3.0 features (reports, CreateCustomFields) that already import moment. companyStore is newly imported in this view to read selectedCompanySettings.
2026-04-07 17:33:03 +02:00
Darko Gjorgjijoski
cb37de6da4 Auto-generate company slug server-side in CompanyRequest
CompanyRequest::getCompanyPayload() accepted 'slug' from the client but never generated it, so the installation wizard (which PUTs /api/v1/company) left the slug empty when setting up the first company. Match the sibling CompaniesRequest (which already does Str::slug($this->name)) and generate the slug from the name server-side; drop the now-unused 'slug' validation rule.

Fixes the same bug that master's ed7af3fc tried to fix client-side with a lodash deburr + regex workaround in Step7CompanyInfo.vue. v3.0's installation wizard is a rewrite under resources/scripts/features/installation/CompanyView.vue and doesn't carry that workaround, so the cleaner fix is to make the backend authoritative like CompaniesRequest already is.
2026-04-07 17:30:34 +02:00
Darko Gjorgjijoski
119a1712b0 Port expense report grouped itemized view + i18n + return types from master
Ports the net behaviour from three master commits into v3.0 as a single change, because v3.0 has already diverged structurally (controller moved from V1/Admin/Report to Company/Report, blade has its own CSS rework using the bundled fonts partial, and v3.0's App\Facades\Pdf replaces Barryvdh\DomPDF\Facade\Pdf). The three source commits are: 834b53ea (grouped itemized expenses), e22050bc (DomPDF facade + Pint — adapted to v3.0's App\Facades\Pdf), 0e9f18d4 (expenses.uncategorized + pdf_expense_group_total_label i18n keys + View|Response return type).

Controller: replaces the expenseCategories aggregate fetch with an itemized Expense query ordered by date, groups by category name with expenses.uncategorized fallback, and shares an expenseGroups collection of {name, expenses, total} plus the overall totalExpense. Adds expense_category_id to applyFilters. Updates the docblock return type from JsonResponse to View|Response. Keeps v3.0's App\Facades\Pdf.

Blade: replaces the single expenseCategories aggregate table with a per-group itemized table (date / note / amount columns + per-group total line using the new pdf_expense_group_total_label i18n key). Adds the item-table-* CSS classes and removes the old expense-total-table bottom block.

lang/en.json: adds expenses.uncategorized = "Uncategorized" and pdf_expense_group_total_label = "Group total:".
2026-04-07 17:28:34 +02:00
Darko Gjorgjijoski
f5e876122d New translations en.json (Hindi) 2026-04-07 17:12:41 +02:00
Darko Gjorgjijoski
66f7dce701 Document the design system in CLAUDE.md and clean up scripts-v2 leftovers
Updates CLAUDE.md to reflect the actual current state of the codebase: rewrites the Frontend section to point at main.ts / features-folder layout / @ alias (the previous text still pointed at the deleted v1 main.js / admin-router.js / admin/stores paths), expands Backend Patterns with the FileDisk + disk-assignments model and the per-user 'default' language sentinel, and adds two new sections — PDF Font System (the on-demand Font Packages mechanism plus the two non-obvious dompdf constraints around variable fonts and font-family fallback) and CSS Theme Tokens (the @theme inline registration model, all currently-defined token categories, the [data-theme=dark] attribute switch, the two-step ritual for adding a new token, and the no-hardcoded-values convention).

Cleans up two scripts-v2 leftovers from the rename refactor: resources/css/invoiceshelf.css had four @source directives — two pointing at the long-deleted scripts-v2/ directory and one pointing at scripts/**/*.js (the new directory has zero .js files, only .vue and .ts), collapsed down to the two correct ones. CONTRIBUTING.md still pointed contributors at 'patterns in resources/scripts-v2/' — fixed to resources/scripts/.
2026-04-07 14:18:49 +02:00
Darko Gjorgjijoski
6fdf10b2b1 Rebuild auth pages on the project design system
Rewrites resources/scripts/layouts/AuthLayout.vue from scratch using only the @theme tokens defined in themes.css and registered via @theme inline in invoiceshelf.css. The new layout is a centered card on the existing bg-glass-gradient utility, using the same visual vocabulary as BaseCard (bg-surface, rounded-xl, border-line-default, shadow-sm) so the auth pages read as a smaller, simpler version of the admin's existing card pattern. Both light and dark mode work automatically because every color references a theme token rather than a hardcoded hex/rgb.

Drops the previous attempt's hardcoded #0a0e1a / #fbbf24 / #f5efe5 palette, the imported Google Fonts (Fraunces / Manrope / JetBrains Mono — replaced with the project default Poppins via font-base), the local --ink / --brass / --cream CSS variables that ignored [data-theme=dark], and the :deep() overrides that forced BaseInput / BaseButton into a custom underline style. The form components now render in the auth card identically to how they render anywhere else in the admin — same components, same theme tokens, no overrides.

Removes four legacy SVG decorations from the original two-panel design: LoginPlanetCrater, LoginBackground, LoginBackgroundOverlay, LoginBottomVector. The page now has no decorative imagery — the bg-glass-gradient utility carries the visual mood.

Adds w-full justify-center to the four auth-form submit buttons (LoginView, ForgotPasswordView, ResetPasswordView, RegisterWithInvitationView) so they fill the auth card width with their labels centered. Done at the call site rather than via :deep() so BaseButton stays untouched and the rest of the admin keeps its inline button style. Route-aware heading/subheading copy is preserved for all four auth views, and the four window.* admin customization hooks (login_page_logo, login_page_heading, login_page_description, copyright_text) still work.
2026-04-07 14:18:34 +02:00
Darko Gjorgjijoski
71388ec6a5 Rename resources/scripts-v2 to resources/scripts and drop @v2 alias
Now that the legacy v1 frontend (commit 064bdf53) is gone, the v2 directory is the only frontend and the v2 suffix is just noise. Renames resources/scripts-v2 to resources/scripts via git mv (so git records the move as renames, preserving blame and log --follow), then bulk-rewrites the 152 files that imported via @v2/... to use @/scripts/... instead. The existing @ alias (resources/) covers the new path with no extra config needed.

Drops the now-unused @v2 alias from vite.config.js and points the laravel-vite-plugin entry at resources/scripts/main.ts. Updates the only blade reference (resources/views/app.blade.php) to match. The package.json test script (eslint ./resources/scripts) automatically targets the right place after the rename without any edit.

Verified: npm run build exits clean and the Vite warning lines now reference resources/scripts/plugins/i18n.ts, confirming every import resolved through the new path. git log --follow on any moved file walks back through its scripts-v2 history.
2026-04-07 12:50:16 +02:00
Darko Gjorgjijoski
064bdf5395 Delete legacy v1 frontend (resources/scripts)
The resources/scripts/ directory was the original Vue 2 / Pinia v1 admin and customer-portal SPA. It has been fully orphaned for some time — vite.config.js has zero entry points pointing at it and the only blade @vite() reference in resources/views/app.blade.php loads scripts-v2/main.ts. The directory was pure dead code.

Removes 424 .vue / .js / store / router / helper files (~2.7 MB) so that resources/scripts-v2/ can be renamed back to resources/scripts/ in a follow-up commit, dropping the v2 suffix now that there is no v1 left.
2026-04-07 12:48:15 +02:00
Darko Gjorgjijoski
f83ec6e78f Pluralize Macedonian status labels for filter tabs and column headers
Status labels in lang/mk.json shipped as neuter singular adjectives in commit 9345d3e5 (Платено, Активно, Повторливо, Прегледано, …) but in the actual UI these strings appear primarily as filter tab labels at the top of list views and as column headers — both contexts read more naturally in plural in Macedonian. Going plural also sidesteps the gender-mismatch problem we'd otherwise hit (Фактура fem., Плаќање neut., Корисник masc.) since Macedonian plural is gender-neutral.

Pluralizes 27 status keys across invoices, estimates, recurring_invoices and settings.preferences: paid → Платени, viewed → Прегледани, overdue → Задоцнети, completed → Завршени, accepted → Прифатени, rejected → Одбиени, expired → Истечени, active → Активни, recurring → Повторливи, one_time → Еднократни, partially_paid → Делумно платени, etc.

Deliberately preserved as singular: general.draft / general.sent and their estimates.* aliases (inherited by single-item header badges), recurring_invoices.on_hold / settings.preferences.on_hold (prepositional phrase, doesn't decline), and settings.exchange_rate.active (per-row is_active flag on a single provider entry).
2026-04-07 12:43:06 +02:00
Darko Gjorgjijoski
9345d3e525 Translate Macedonian (mk) locale to ~99% coverage
lang/mk.json was an empty Crowdin stub with only 16 of 1591 keys translated (~1%), and several of those were stale English variants from older en.json revisions ('Roles' instead of 'Company Roles', 'Notes' instead of 'Record Notes', 'Backup' instead of 'Backups', etc.) that survived as pseudo-translations. Replace the stub with a full Cyrillic translation covering 1578 of 1591 keys (~99%), with structural parity to en.json (zero missing keys, identical 1769-line shape, valid JSON).

The 13 deliberately untranslated entries are brand names and technical conventions that should not be localized: CC, BCC, 404, EXP-001, Slug, Placeholder, URL, dropbox, and the four exchange-rate provider proper nouns (Currency Freak, Currency Layer, Open Exchange Rate, Currency Converter). Industry-standard transliterations are used where Macedonian doesn't have a settled term (Драјвер for driver, S3 / AWS / Mailgun / Dropbox kept as proper nouns, ДДВ for VAT). Vue-i18n pluralization syntax preserved (e.g. 'Фактура | Фактури', 'Корисник | Корисници').

All sidebar menu titles, form labels, validation messages, settings pages, wizard flow, error states, PDF labels and email templates are translated. Re-rendering the SPA after a hard refresh shows the full admin UI in Cyrillic when the company language is set to Macedonian.
2026-04-07 12:35:18 +02:00
Darko Gjorgjijoski
fac9ad8cef Add Hebrew and Urdu to the company language dropdown
Both lang/he.json and lang/ur.json shipped as empty Crowdin stubs but never made it into config('invoiceshelf.languages'), so admins could not pick either locale even after the matching font packages (noto-sans-hebrew, noto-naskh-arabic) landed in commit 04952d91. Add the two entries with native names (עברית, اردو) following the existing Svenska / ไทย / Tiếng Việt convention for non-Latin scripts.

Inserted alphabetically by English name — he between Greek and Hindi, ur after Ukrainian. The first PDF render for either locale will trigger ensureFontsForLocale() and synchronously install the corresponding font package; the UI itself stays mostly English until the Crowdin translations catch up, which is the intended trade-off — these locales are useful primarily as language tags for PDF rendering of Hebrew / Arabic-script customer data.
2026-04-07 12:35:01 +02:00
Darko Gjorgjijoski
04952d91ed Add Hebrew/Arabic/Devanagari/Sarabun font packages and unify Noto Sans into the package array
Closes the audit gaps from the original font system commit. The bundled NotoSans only covered Latin/Greek/Cyrillic but the descriptions claimed Arabic, Thai and Hindi too — that was false. DejaVu Sans, the prior dompdf default, did cover Hebrew, Arabic, Armenian and Georgian, so swapping it for NotoSans had silently regressed those scripts. The Thai conditional include was also dropped from every PDF template in that commit, leaving th locales rendering boxes despite THSarabunNew still sitting in resources/static/fonts/.

Adds four on-demand Font Packages — Noto Sans Hebrew, Noto Naskh Arabic (covering Arabic, Persian, Urdu, Sorani Kurdish), Noto Sans Devanagari (Hindi, Marathi, Sanskrit, Nepali) and Sarabun (Thai) — sourced from openmaptiles/fonts and google/fonts as static TTF. Static is mandatory because dompdf's PHP-Font-Lib does not parse variable fonts. Sarabun replaces THSarabunNew as the Thai face: same designer, OFL-licensed, maintained on a stable upstream URL, and surfaces through the same install flow as every other non-Latin script. The bundled THSarabunNew TTF files and the dead app/pdf/locale/th.blade.php legacy partial are removed as part of the migration.

Unifies the bundled Noto Sans into FONT_PACKAGES as a noto-sans entry with bundled => true and files served from resources/static/fonts/ instead of storage/fonts/. FontService::isInstalled, downloadPackage, getInstalledFontFaces and getPackageStatuses honor the flag through a new packageDir() helper. The hardcoded @font-face block in the PDF partial is gone — fonts.blade.php collapses to a single getInstalledFontFaces() call so the package array is the only source of truth for every face, bundled or on-demand. Admin → Font Packages now lists Noto Sans at the top with a primary-colored Bundled pill (new settings.fonts.bundled string) alongside the existing Installed badge / Install button states.

Also fixes the misleading settings.fonts.description and settings.fonts.bundled_info copy to actually describe what ships out of the box vs. what's optional, and rebuilds the en locale chunk.
2026-04-07 11:50:34 +02:00
Darko Gjorgjijoski
27c60bb6f5 Allow modal dropdowns and tooltips to overflow their panel
Switch the BaseModal panel container from overflow-hidden to overflow-visible so multiselect dropdowns, autocompletes and tooltips can render outside the modal bounds. Same fix as the settings layout panel earlier — Headless UI's dialog still clips at the viewport, and the modal panel keeps its rounded-xl corners through its own bg-surface background, so removing the clip exposes no square-corner regression on existing modals.
2026-04-07 11:50:10 +02:00
Darko Gjorgjijoski
53932e9e16 Pluralize admin settings menu items and clarify Font Packages copy
Sidebar entries Backup and File Disk now read Backups and File Disks to match how the underlying pages actually behave (each one lists/manages multiple entries) and to align with the recently added Font Packages entry. The settings.{backup,disk}.title pluralization slots are collapsed to plural-only so the existing $t(..., 1) call sites in AdminBackupView, AdminFileDiskView and the legacy v1 BackupSetting view render the plural form on the page header without touching any Vue code.

Also finishes the Fonts → Font Packages rename: menu_title.fonts and fonts.title both read Font Packages, and fonts.description leads with 'used exclusively when generating PDF documents' so the PDF-only scope is unmissable.
2026-04-07 10:53:45 +02:00
Darko Gjorgjijoski
5ab9c5f736 Allow settings panel dropdowns to overflow their container
Switch the settings layout content wrapper from overflow-hidden to overflow-visible in SettingsLayoutView, UserSettingsLayoutView and AdminSettingsView so multiselect dropdowns and tooltips can render outside the panel without being clipped.
2026-04-07 07:55:00 +02:00
Darko Gjorgjijoski
78ed332d06 Add per-user language preference with company default fallback
Existing accounts inherited the company language at creation time and there was no way to change UI language per user. Add a 'Default (Company Language)' entry to the language selector in UserGeneralView, persist the choice through userStore.updateUserSettings and reload the i18n bundle via window.loadLanguage. The 'default' sentinel keeps the user opted in to the company-wide setting.

Bootstrap (global.store) now syncs userForm from current_user data and resolves the active UI language as user > company > 'en'. RegisterController, InvitationRegistrationController and MemberService seed new users with language=default instead of copying the current company setting, so promoting/inviting members no longer leaks the inviter's frozen language.
2026-04-07 04:41:00 +02:00
Darko Gjorgjijoski
c5c9677ffc Add Admin Fonts settings page to install CJK font packages
Adds AdminFontView with package list, install buttons, status indicators and toast notifications backed by /api/v1/fonts/status and /api/v1/fonts/{package}/install. Wires the new admin.settings.fonts lazy route and a Languages-icon menu entry under Admin → Settings.
2026-04-07 01:17:00 +02:00
Darko Gjorgjijoski
ba5c6c39ba Add multilingual PDF font system with Noto Sans and on-demand CJK packages
Bundle Noto Sans (Regular/Bold/Italic/BoldItalic) under resources/static/fonts/ as the default PDF face — it covers Latin, Cyrillic, Greek, Arabic, Thai and Hindi out of the box, replacing the limited DejaVu Sans fallback. Move all @font-face declarations into app.pdf.partials.fonts and include it from every invoice/estimate/payment/report template, dropping per-template font-family hardcodes and the conditional Thai locale include.

Introduce FontService + FontController to download static Noto Sans CJK packages (zh, zh_CN, ja, ko) from life888888/cjk-fonts-ttf on demand. GeneratesPdfTrait::ensureFontsForLocale primes the family before rendering and the partial emits @font-face rules for installed packages so dompdf resolves them through standard CSS — no separate registerFont() instance required. Static TTFs are mandatory because dompdf's PHP-Font-Lib does not parse variable fonts (fvar/gvar tables), which is why Google Fonts' NotoSansTC[wght].ttf rendered empty boxes.

Expose status/install via /api/v1/fonts/status and /api/v1/fonts/{package}/install with matching FONTS_STATUS / FONTS_INSTALL constants in scripts-v2/api/endpoints.ts. Flip DOMPDF_ENABLE_REMOTE default to true for remote asset loading.
2026-04-06 23:32:00 +02:00
Darko Gjorgjijoski
346e5df7ee Fix Thai font path with duplicated static/ segment in PDF locale partial
The @font-face URLs in resources/views/app/pdf/locale/th.blade.php pointed to resource_path('static/static/fonts/THSarabunNew*.ttf'), which does not exist on disk, so dompdf fell back to a default face when rendering Thai PDFs. Drop the duplicated segment so the bundled THSarabunNew TTFs resolve correctly.
2026-04-06 21:48:00 +02:00
Darko Gjorgjijoski
9c3013bb24 Add cache clearing and auto-migrate to Docker entrypoint
Clears config and application cache on every container start to
prevent stale provider references after image updates. Creates the
storage symlink and runs pending migrations if the app is already
installed.

Fixes #614
2026-04-07 02:17:18 +02:00
Darko Gjorgjijoski
5efd1054f4 Add v3.0 upgrade migration
Migrates media disk references from the old temp_{driver} naming to
the new disk_{id} scheme. System local disks map to 'local', remote
disks map to 'disk_{id}'. Structured as the single v3.0 upgrade
migration for future additions.
2026-04-07 02:12:38 +02:00
Darko Gjorgjijoski
20085cab5d Refactor FileDisk system with per-disk unique names and disk assignments UI
Major changes to the file disk subsystem:

- Each FileDisk now gets a unique Laravel disk name (disk_{id}) instead
  of temp_{driver}, fixing the bug where multiple local disks with
  different roots overwrote each other's config.

- Move disk registration logic from FileDisk model to FileDiskService
  (registerDisk, getDiskName). Model keeps only getDecodedCredentials
  and a deprecated setConfig() wrapper.

- Add Disk Assignments admin UI (File Disk tab) with three purpose
  dropdowns: Media Storage, PDF Storage, Backup Storage. Stored as
  settings (media_disk_id, pdf_disk_id, backup_disk_id).

- Backup tab now uses the assigned backup disk instead of a per-backup
  dropdown. BackupsController refactored to use BackupService which
  centralizes disk resolution. Removed stale 4-second cache.

- Add local_public disk to config/filesystems.php so system disks
  are properly defined.

- Local disk roots stored relative to storage/app/ with hint text
  in the admin modal explaining the convention.

- Fix BaseModal watchEffect -> watch to prevent infinite request
  loops on the File Disk page.

- Fix string/number comparison for disk purpose IDs from settings.

- Add safeguards: prevent deleting disks with files, warn on
  purpose change, prevent deleting system disks.
2026-04-07 02:04:57 +02:00
Darko Gjorgjijoski
ea1fc9b799 Consolidate media disk config into AppConfigProvider
Remove duplicate configureMediaDisk() from AppServiceProvider — all
FileDisk and media-library config is now in AppConfigProvider's
configureFileSystemFromDatabase().

Replace setConfig() calls with inline config registration everywhere
to avoid mutating filesystems.default, which caused infinite request
loops on the File Disk admin page.
2026-04-07 01:09:06 +02:00
Darko Gjorgjijoski
6dd9ed1232 Fix infinite request loop on File Disk admin page
configureMediaDisk() was calling FileDisk::setConfig() which mutates
the global filesystems.default config on every request. This caused
cascading requests on the File Disk admin page.

Now registers the media disk config directly without changing the
global default filesystem.
2026-04-07 01:05:08 +02:00
Darko Gjorgjijoski
67268ac2b7 Secure expense receipts by wiring Media Library to FileDisk
Spatie Media Library now uses the default FileDisk (local_private) for
new uploads instead of the public disk. Expense receipts are no longer
directly web-accessible.

- AppServiceProvider configures media-library disk from FileDisk on boot
- Change media-library fallback from 'public' to 'local'
- Expense receipt URL accessor returns authenticated route instead of
  direct file URL
- Add registerMediaCollections() to Expense model
- Prevent deleting FileDisk that contains files or is a system disk
- Add media:secure command to migrate existing receipts to private disk

Fixes #187
2026-04-07 01:01:59 +02:00
Darko Gjorgjijoski
39c9179888 Support internationalized domain names (IDN) in email validation
Add IdnEmail validation rule that converts IDN domains to Punycode
via idn_to_ascii() before validating with FILTER_VALIDATE_EMAIL.
Applied to all email fields: customers, members, profiles, admin
users, customer portal profiles, and mail configuration.

Includes unit tests for standard emails, IDN emails, and invalid
inputs.

Fixes #388
2026-04-06 23:55:29 +02:00
Darko Gjorgjijoski
631d838834 Fix recurring invoices using wrong date in non-UTC timezones
Pass the app's configured timezone to CronExpression::getNextRunDate()
so the next invoice date is calculated in the correct timezone instead
of defaulting to UTC.

Fixes #491
2026-04-06 23:38:55 +02:00
Darko Gjorgjijoski
9638e02eb8 Fix customer portal not reflecting company default currency
The customer portal bootstrap now returns current_company_currency
alongside the customer's own currency. The store falls back to the
company currency when the customer has no currency assigned.

Fixes #142
2026-04-06 23:37:56 +02:00
Darko Gjorgjijoski
c46118be3b Add Icelandic Króna (ISK) to currency seeder
ISK uses 0 decimal precision (no fractional units), dot thousand
separator, comma decimal separator, and symbol after the number.

Fixes #348
2026-04-06 23:27:15 +02:00
Darko Gjorgjijoski
0093bf4d53 Copy custom fields when converting estimate to invoice
Custom fields defined on an estimate are now carried over to the
invoice when using Convert to Invoice. Uses the same pattern as
the clone method.

Fixes #282
2026-04-06 23:24:11 +02:00
Darko Gjorgjijoski
25b61b73a0 Fix case-sensitive email login
Email comparison on login now uses LOWER() for case-insensitive
matching. Applied to both admin and customer portal login controllers.

Fixes #424
2026-04-06 23:22:16 +02:00
Darko Gjorgjijoski
8508e7e1b8 Show user role in company switcher
CompanyResource now includes user_role — the authenticated user's
Bouncer role title scoped to that company (e.g. "Owner"). Displayed
as a subtitle under each company name in the switcher dropdown.
2026-04-06 23:03:29 +02:00
Darko Gjorgjijoski
9ca998e64a Add Convert to Estimate feature for invoices
New backend endpoint POST /invoices/{id}/convert-to-estimate that
creates a draft estimate from an invoice, copying items, taxes,
custom fields, and financial data. Frontend wired with dropdown
action, store method, and API service call.
2026-04-06 22:57:03 +02:00
Darko Gjorgjijoski
c328d1cd10 Mount send modals on index views and dashboard, pass missing props
SendInvoiceModal and SendEstimateModal were only mounted on detail
views. Resend from table dropdowns did nothing because the modal
component was not in the DOM. Added to index views and dashboard.

Pass canCreatePayment and canCreateEstimate props to InvoiceDropdown
from detail view and dashboard.
2026-04-06 22:56:57 +02:00
Darko Gjorgjijoski
5c0e761dfa Fix copy PDF URL and dropdown action conditions
Copy PDF URL now checks window.isSecureContext before using
navigator.clipboard, falls back to textarea+execCommand on HTTP,
and shows a success notification.

Invoice dropdown: Mark as Sent uses its own condition instead of
reusing Send, Resend hidden in detail view.

Estimate dropdown: Mark as Accepted/Rejected hidden when already in
the other terminal state, Convert to Invoice hidden on rejected
estimates. Added Convert to Estimate action for invoices.
2026-04-06 22:56:49 +02:00
Darko Gjorgjijoski
6106ac8208 Fix send modals: from email and preview rendering
fetchBasicMailConfig() was calling the wrong endpoint (company-config
instead of default config). Also, the response has no .data wrapper so
from_mail was never extracted. Fixed in all three send modals.

Estimate and payment preview blob construction now falls back to the
raw response when .data is undefined, matching the invoice modal.
2026-04-06 22:56:41 +02:00
Darko Gjorgjijoski
45f347ebef Fix global tax recalculation and fractional cent totals
Global percentage taxes are now recalculated when items or discount
change, preventing stale tax amounts. Math.round() applied to
sub_total, total, and tax in invoice/estimate submit payloads to
ensure the backend always receives whole-cent integers.
2026-04-06 22:56:31 +02:00
Darko Gjorgjijoski
b07a252523 Fix default template not pre-selected on document create
Read default_invoice_template and default_estimate_template from
useUserStore().currentUserSettings instead of relying on a parameter
that was never passed from the create views.
2026-04-06 22:56:24 +02:00
Darko Gjorgjijoski
b0b7d40c73 Fix exchange rate parity across all document types
- Fix exchange-rate service types to match actual backend response shapes
  (exchangeRate array, activeProvider success/error, used currencies as strings)
- Add ExchangeRateConverter to payments, expenses, and recurring invoices
- Set currency_id from customer currency in invoice/estimate selectCustomer()
- Load globalStore.currencies in ExchangeRateConverter on mount
- Pass driver/key/driver_config params to getSupportedCurrencies in provider modal
- Fix OpenExchangeRateDriver validateConnection to use base=USD (free plan compat)
- Fix checkActiveCurrencies SQLite whereJsonContains with array values
- Remove broken currency/companyCurrency props from ExpenseCreateView, use stores
- Show base currency equivalent in document line items and totals when exchange
  rate is active
2026-04-06 21:07:50 +02:00
Darko Gjorgjijoski
e64529468c Replace deleted_files with manifest-based updater cleanup, add release workflow
- Add manifest.json generation script (scripts/generate-manifest.php)
- Add Updater::cleanStaleFiles() that removes files not in manifest
- Add /api/v1/update/clean endpoint with backward compatibility
- Add configurable update_protected_paths in config/invoiceshelf.php
- Update frontend to use clean step instead of delete step
- Add GitHub Actions release workflow triggered on version tags
- Add .github/release.yml for auto-generated changelog categories
- Update Makefile to include manifest generation and scripts directory
2026-04-06 19:27:33 +02:00
Darko Gjorgjijoski
2bdfb6a8b6 Add contributing documentation 2026-04-06 18:00:18 +02:00
Darko Gjorgjijoski
74b4b2df4e Finalize Typescript restructure 2026-04-06 17:59:15 +02:00
Darko Gjorgjijoski
cab785172e Add missing base components and global alias registrations
Create BaseCustomTag (dynamic tag render), BaseFormatMoney,
BaseHeading, BaseScrollPane, BaseDescriptionList/Item, BaseLabel,
BaseCustomerSelectInput, BaseSpinner, BaseRating, and status label
components. Register all renamed v2 components under their old
Base* names (BaseInputGroup->FormGroup, BasePage->Page,
BaseTable->DataTable, etc.) so templates resolve correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:45:00 +02:00
Darko Gjorgjijoski
97f88eaf2c Fix build warnings: add isImageFile util, exclude heavy components
from eager glob, clean up dynamic import conflicts

- Add missing isImageFile() to format-money utils
- Exclude BaseMultiselect, InvoicePublicPage, InvoiceInformationCard
  from eager glob in global-components.ts using negative patterns
- Short-circuit English locale in i18n to avoid redundant dynamic import
- Only en.json warning remains (intentional: English bundled inline)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:15:00 +02:00
Darko Gjorgjijoski
af92a361a5 Fix auth endpoints: LOGIN and LOGOUT are web routes, not API routes
LOGIN was posting to /api/v1/auth/login but the actual route is
POST /login (session-based web route). LOGOUT was /api/v1/auth/logout
but the actual is POST /auth/logout. All 76+ other endpoints verified
correct against routes/api.php.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:00:00 +02:00
Darko Gjorgjijoski
a46cca5cd8 Complete scripts-v2 TypeScript migration — all imports resolved,
build passes

Create all missing components (modals, dropdowns, icons, tabs, mail
drivers, customer partials), fix all @/scripts/ imports to @v2/,
wire up vite entry point and blade template. 382 files, 48883 lines.

- 27 settings components: modals (tax, payment, custom field, note,
  category, role, exchange rate, unit, mail test), dropdowns (6),
  customization tabs (4), mail driver forms (4)
- 22 icon components: 5 utility icons, 4 dashboard icons, 13 editor
  toolbar icons with typed barrel export
- 3 customer components: info, chart placeholder, custom fields single
- Fixed usePopper composable, client/format-money import patterns
- Zero remaining @/scripts/ imports in scripts-v2/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 09:30:00 +02:00
Darko Gjorgjijoski
812956abcc Phase 5-6: Router, plugins, entry points — scripts-v2 complete
13 files completing the TypeScript migration:
- router/ (3 files): typed guards, route meta augmentation,
  merged feature routes from all 16 modules
- plugins/ (4 files): i18n with dynamic locale loading, pinia,
  tooltip directive
- Entry points: main.ts, InvoiceShelf.ts bootstrap class,
  App.vue, global-components.ts with typed registration
- NoCompanyView and NotFoundView stubs

scripts-v2/ totals: 324 files, 42853 lines of strict TypeScript.
Zero any types. Complete feature-based architecture with typed
stores, API services, composables, and Vue components.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:00:00 +02:00
Darko Gjorgjijoski
d91f6ff2e3 Phase 4b: Remaining features — payments, expenses, recurring
invoices, members, reports, settings, customer portal, modules,
installation

82 files, 14293 lines. Completes all feature modules:
- payments: CRUD with send/preview, payment modes
- expenses: CRUD with receipt upload, categories
- recurring-invoices: full frequency logic, limit by date/count
- members: list with roles, invite modal, pending invitations
- reports: sales, profit/loss, expenses, tax with date ranges
- settings: 14 settings views, number customizer, mail config
- customer-portal: consolidated store, 8 views, portal layout
- modules: marketplace index, detail/install, module cards
- installation: 8-step wizard with requirements/db/mail/account

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:30:00 +02:00
Darko Gjorgjijoski
774b2614f0 Phase 4a: Feature modules — layouts, auth, admin, dashboard,
customers, items, invoices, estimates, shared document form

77 files, 14451 lines. Typed layouts (CompanyLayout, AuthLayout,
header, sidebar, company switcher), auth views (login, register,
forgot/reset password), admin feature (dashboard, companies, users,
settings with typed store), company features (dashboard with chart/
stats, customers CRUD, items CRUD, invoices CRUD with full store,
estimates CRUD with full store), and shared document form components
(items table, item row, totals, notes, tax popup, template select,
exchange rate converter, calculation composable).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 06:30:00 +02:00
Darko Gjorgjijoski
e43e515614 Phase 3: Typed Vue components in scripts-v2/
Migrate all shared components to TypeScript SFCs with script setup
lang=ts. 72 files, 7144 lines, zero any types.

- components/base/ (42 files): Button, Input, Textarea, Checkbox,
  Radio, Switch, Badge, Card, Modal, Dialog, Dropdown, DatePicker,
  TimePicker, Money, FileUploader, Select, Icon, Loader, Multiselect,
  TabGroup, Wizard, CustomerSelect, ItemSelect, CustomInput, alerts,
  status badges (Invoice/Estimate/Paid/RecurringInvoice), List/ListItem
- components/table/ (3 files): DataTable, TablePagination
- components/form/ (4 files): FormGroup, FormGrid, SwitchSection
- components/layout/ (11 files): Page, PageHeader, Breadcrumb,
  FilterWrapper, EmptyPlaceholder, ContentPlaceholders, SettingCard
- components/editor/ (2 files): RichEditor with Tiptap
- components/charts/ (2 files): LineChart with Chart.js
- components/notifications/ (3 files): NotificationRoot, NotificationItem
- components/icons/ (2 files): MainLogo

All use defineProps<Props>(), defineEmits<Emits>(), typed refs,
and import domain types from types/domain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 05:45:00 +02:00
Darko Gjorgjijoski
2b996d30bf Phase 2: Typed global Pinia stores in scripts-v2/
Rewrite all 7 global stores from JS options API to TypeScript
composition API. 8 files, 1005 lines, zero any types.

- auth.store.ts: login/logout with authService
- global.store.ts: bootstrap, menus, sidebar, config fetching
- company.store.ts: company selection, admin mode, settings
- user.store.ts: current user, abilities, settings
- notification.store.ts: typed toast notifications
- dialog.store.ts: confirm dialog returning Promise<boolean>
- modal.store.ts: modal state with isEdit getter

All use async/await, typed API services, localStore utility,
and explicit ref<T> generics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 05:15:00 +02:00
Darko Gjorgjijoski
991b716b33 Phase 1: TypeScript foundation in scripts-v2/
Create the complete TypeScript foundation for the Vue 3 migration
in a parallel scripts-v2/ directory. 72 files, 5430 lines, zero
any types, strict mode.

- types/ (21 files): Domain interfaces for all 17 entities derived
  from actual Laravel models and API resources. Enums for all
  statuses. Generic API response wrappers.
- api/ (29 files): Typed axios client with interceptors, endpoint
  constants from routes/api.php, 25 typed service classes covering
  every API endpoint.
- composables/ (14 files): Vue 3 composition functions for auth,
  notifications, dialogs, modals, pagination, filters, currency,
  dates, theme, sidebar, company context, and permissions.
- utils/ (5 files): Pure typed utilities for money formatting,
  date formatting (date-fns), localStorage, and error handling.
- config/ (3 files): Typed ability constants, app constants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 05:00:00 +02:00
Darko Gjorgjijoski
53a0e1491d Refine sidebar collapse toggle to bottom toolbar
Move collapse button from header into a sticky bottom toolbar in
the sidebar. Chevron double-left/right icon aligned to sidebar edge.
Toolbar area uses mt-auto with border-t separator and glass backdrop,
ready for additional action buttons in the future.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 04:00:00 +02:00
Darko Gjorgjijoski
827c5c8938 Add collapsible sidebar with icon-only mode and tooltips
Desktop sidebar can be toggled between full width (w-56/w-64) and
collapsed icon-only mode (w-16). Collapsed state persists in
localStorage. Icons enlarge to w-6 h-6 when collapsed, labels
hidden, v-tooltip shows item names on hover. Group labels replaced
with thin dividers when collapsed. Toggle button pinned at bottom
with ChevronLeft/Right icon. Content area padding animates smoothly
with transition-all duration-300.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:45:00 +02:00
Darko Gjorgjijoski
9c8e4ae558 Add glass UI with backdrop blur and fix primary button colors
Apply glassmorphism to sidebar, cards, tables, modals, dropdowns,
and dialogs with semi-transparent backgrounds, backdrop-blur, and
white/15 borders. Add subtle gradient body background for the blur
to work against. Add dedicated btn-primary color tokens so primary
buttons stay bold in dark mode instead of using the brightened text
palette. Use shadow-sm to avoid heavy halos in light mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:30:00 +02:00
Darko Gjorgjijoski
7e15eb7c7a Make settings sidebar sticky and consistent across all settings pages
Add gap-8 to user settings (was missing), and sticky top-20
self-start to all three settings sidebars (company, admin, user
profile) so the menu stays fixed while the content area scrolls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:22:00 +02:00
Darko Gjorgjijoski
a10d4d2de9 Theme scrollbars for dark mode
Add global webkit and Firefox scrollbar styling using semantic
color tokens. Fix component scrollbar classes in GlobalSearchBar
and CompanySwitcher from hardcoded gray to theme-aware colors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:15:00 +02:00
Darko Gjorgjijoski
249b2759b6 Fix header gradient too light in dark mode
Add dedicated header-from/header-to color tokens that are independent
of the primary palette dark mode overrides. Dark mode header uses a
deeper indigo gradient instead of the brightened primary colors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:08:00 +02:00
Darko Gjorgjijoski
d7f88d3103 Fix + button icon color in header to white
The bulk sed migration changed the PlusIcon from text-gray-600 to
text-body, but it sits on the gradient header and should be white.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:00:00 +02:00
Darko Gjorgjijoski
960e6d3517 Polish header search bar with frosted glass styling
Replace solid bg-surface background with bg-white/20 translucent
style matching the + button and company switcher. Use white text
and placeholder with opacity for consistency on the gradient header.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:55:00 +02:00
Darko Gjorgjijoski
7a1e2cd2c3 Rename Notes to Record Notes in company settings menu
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:48:00 +02:00
Darko Gjorgjijoski
d36fbbbf27 Add green status indicator for global mail configuration
Show a green check icon with tinted background when company is
using the global mail configuration, replacing the plain gray text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:42:00 +02:00
Darko Gjorgjijoski
2a3774f437 Soften divider lines across settings pages
Change BaseDivider from text-subtle (which left the hr with a dark
default border) to border-line-light for a gentle themed separator.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:35:00 +02:00
Darko Gjorgjijoski
dabdd2b417 Fix status badge colors: SENT to green, PAID more visible
Change SENT status from yellow to green in both invoice and estimate
badges. Make PAID badge more noticeable with stronger green background
(40% opacity) and semibold text. Use consistent text-status-green
token for PAID across all badge components.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:30:00 +02:00
Darko Gjorgjijoski
350068706c Unify form styling across invoice, estimate, and recurring invoice pages
Apply consistent rounded-xl shadow border-line-light to customer
selector, date/number fields card, items table, totals card, editor,
tax popup, and view page sidebars. Wrap right-side basic fields in
card container matching the customer card.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:18:00 +02:00
Darko Gjorgjijoski
88adfe0e50 Add dark mode with CSS custom property theme system
Define 13 semantic color tokens (surface, text, border, hover) with
light/dark values in themes.css. Register with Tailwind via @theme inline.
Migrate all 335 Vue files from hardcoded gray/white classes to semantic
tokens. Add theme toggle (sun/moon/system) in user avatar dropdown.
Replace @tailwindcss/forms with custom form reset using theme vars.
Add status badge and alert tokens for dark mode. Theme-aware chart
grid/labels, skeleton placeholders, and editor. Inline script in
<head> prevents flash of wrong theme on load.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:05:00 +02:00
Darko Gjorgjijoski
7fbe3d85a3 UI: Facelift for v3 2026-04-04 01:52:03 +02:00
Darko Gjorgjijoski
eb0a588164 Refactor Administration entrypoint
We moved the administration item to the company switcher in the header
2026-04-04 01:36:28 +02:00
Darko Gjorgjijoski
29b3abd317 Add role column to members table and reorder admin settings menu
Display the Bouncer role title in the members list table. Move
Update App to the last position in administration settings menu.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 01:00:00 +02:00
Darko Gjorgjijoski
c85051161b Improve NoCompanyView design and fix header for no-company state
Personalize welcome heading with user name, add descriptive subtitle,
improve invitation card styling, remove redundant logout button. Fix
hasCreateAbilities check in header to actually call the function.
Widen company switcher dropdown and improve invitation row layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:48:00 +02:00
Darko Gjorgjijoski
c3a59a46db Add frontend handling for users without a company
Make setSelectedCompany null-safe and clear stale localStorage.
Conditionally initialize company store state in bootstrap. Add
router guard to redirect no-company users to NoCompanyView while
allowing super admins through. Hide sidebar when no company.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:36:00 +02:00
Darko Gjorgjijoski
fae59221d3 Generate admin menus for super admins without a company
Super admin users with no company associations now receive their
administration menu items in the bootstrap response instead of
empty arrays.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:24:00 +02:00
Darko Gjorgjijoski
afbc6c1db3 Handle no-company user in ScopeBouncer middleware and User model
Skip bouncer scoping when user has no companies instead of crashing
on null. Fall back to Y-m-d date format in getFormattedCreatedAtAttribute
when no company settings are available.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:12:00 +02:00
Darko Gjorgjijoski
c49c130e9e Remove Add Member button and Create page — members are added via invitation only 2026-04-04 00:00:53 +02:00
Darko Gjorgjijoski
51f0e6285b Fix session not invalidated on logout causing CSRF mismatch on re-login
The web logout route called Auth::guard('web')->logout() but didn't
invalidate the session or regenerate the CSRF token. The browser kept
sending the old session cookie, causing CSRF token mismatch errors
when logging in as a different user.
2026-04-03 23:52:07 +02:00
Darko Gjorgjijoski
03afb98452 Fix logout not clearing auth token and company from localStorage
After logout, the old auth.token and selectedCompany stayed in
localStorage. On next login, the http interceptor sent the stale
token in the Authorization header, causing all API calls to fail
with 401/419 even though the new session was valid.
2026-04-03 23:49:47 +02:00
Darko Gjorgjijoski
acce67f514 Fix CSRF token mismatch after logout by refreshing cookie
After logout invalidates the session, the SPA still holds the old CSRF
cookie. Subsequent login attempts succeed but bootstrap/API calls fail
with CSRF mismatch, causing redirect back to login. Fix: fetch a fresh
CSRF cookie via /sanctum/csrf-cookie after logout completes.
2026-04-03 23:46:07 +02:00
Darko Gjorgjijoski
8e966965f5 Add logo to invitation registration page 2026-04-03 23:41:27 +02:00
Darko Gjorgjijoski
e0e302e1cf Add /register web route to serve SPA for invitation registration
The SPA catch-all only handles /admin/{vue?}. The /register route needs
its own web route to serve the app view so Vue router can handle it.
2026-04-03 23:39:14 +02:00
Darko Gjorgjijoski
8d3029c877 Fix invitation email: load relationships and handle mail failures gracefully
The CompanyInvitationMail accesses company, role, and invitedBy
relationships which weren't loaded before sending. Also wrap mail
send in try-catch so the invitation is still created even if the
mailer is misconfigured (logs a warning instead of crashing).
2026-04-03 23:30:13 +02:00
Darko Gjorgjijoski
c1994887ef Support invitations for unregistered users
When inviting an email without an InvoiceShelf account, the email now
links to a registration page (/register?invitation={token}) instead of
login. After registering, the invitation is auto-accepted.

Backend:
- InvitationRegistrationController: public details() and register()
  endpoints. Registration validates token + email match, creates account,
  auto-accepts invitation, returns Sanctum token.
- AuthController: login now accepts optional invitation_token param to
  auto-accept invitation for existing users clicking the email link.
- CompanyInvitationMail: conditional URL based on user existence.
- Web route for /invitations/{token}/decline (email decline link).

Frontend:
- RegisterWithInvitation.vue: fetches invitation details, shows company
  name + role, registration form with pre-filled email.
- Router: /register route added.

Tests: 3 new tests (invitation details, register + accept, email mismatch).
2026-04-03 23:26:58 +02:00
Darko Gjorgjijoski
6343b4a17f Add invitation frontend: invite modal, pending invitations, no-company view
Members Index:
- "Invite Member" button opens InviteMemberModal (email + role dropdown)
- Pending invitations section shows below members table with cancel buttons
- Members store gains inviteMember, fetchPendingInvitations, cancelInvitation

CompanySwitcher:
- Shows pending invitations greyed out below active companies
- Each with Accept/Decline mini-buttons
- Accepting refreshes bootstrap and switches to new company

NoCompanyView:
- Standalone page for users with zero accepted companies
- Shows pending invitations with Accept/Decline or "no companies" message
- Route: /admin/no-company

Invitation Pinia store:
- Manages user's own pending invitations (fetchPending, accept, decline)
- Bootstrap populates invitations from API response

Global store:
- Bootstrap action stores pending_invitations from response
2026-04-03 23:20:41 +02:00
Darko Gjorgjijoski
8a6c085288 Rename company-scoped Users to Members throughout
Complete rename across backend and frontend:
- Controller: Company/Users/UsersController -> Company/Members/MembersController
- Service: UserService -> MemberService
- Requests: UserRequest -> MemberRequest, DeleteUserRequest -> DeleteMemberRequest
- API routes: /api/v1/users -> /api/v1/members (company-scoped only)
- Sidebar menu: "Users" -> "Members"
- Frontend: views/users -> views/members, stores/users -> stores/members
- Router: users.index -> members.index, /admin/users -> /admin/members
- i18n: new "members" section with invitation-related keys
- Tests: UserTest -> MemberTest

Admin/super-admin Users (system-wide user management) remains unchanged.
2026-04-03 23:12:30 +02:00
Darko Gjorgjijoski
92a1baced4 Add company invitation system (backend)
New feature allowing company owners/admins to invite users by email with
a specific company-scoped role.

Database:
- New company_invitations table (company_id, email, role_id, token,
  status, invited_by, expires_at)

Backend:
- CompanyInvitation model with pending/forUser scopes
- InvitationService: invite, accept, decline, getPendingForUser
- CompanyInvitationMail with markdown email template
- InvitationController (company-scoped): list, send, cancel invitations
- InvitationResponseController (user-scoped): pending, accept, decline
- BootstrapController returns pending_invitations in response
- CompanyMiddleware handles zero-company users gracefully

Tests: 9 feature tests covering invite, accept, decline, cancel, expire,
duplicate prevention, and bootstrap integration.
2026-04-03 22:58:55 +02:00
Darko Gjorgjijoski
4318c59976 Add Star History chart to README 2026-04-03 22:41:15 +02:00
Darko Gjorgjijoski
c3ad718799 Document Service pattern and TDD requirements in CLAUDE.md and AGENTS.md
Add mandatory Service pattern guidelines: all business logic in Services,
thin controllers, clean models. Document TDD approach with feature tests
for routes and unit tests for services. Add role definitions (super admin
vs owner) to AGENTS.md.
2026-04-03 22:38:53 +02:00
Darko Gjorgjijoski
dee17a1da8 Rename Roles to Company Roles in settings menu 2026-04-03 22:35:50 +02:00
Darko Gjorgjijoski
00d5abae5f Eliminate Company\CompaniesController, introduce owner role
Redistribute methods:
- show() -> BootstrapController::currentCompany()
- store(), destroy(), userCompanies() -> Admin\CompaniesController
- transferOwnership() -> CompanySettingsController

Security fix: introduce 'owner' role for company-level admin, distinct
from 'super admin' which is now global platform admin only.
- CompanyService::setupRoles() creates 'owner' role per company
- Company creation assigns scoped 'owner' role instead of global 'super admin'
- Seeders updated to assign 'owner'

Migration renames all existing company-scoped 'super admin' roles to
'owner' and ensures every company owner has the role assigned.
2026-04-03 22:33:56 +02:00
Darko Gjorgjijoski
5912995164 Move CompaniesController from Company/Company/ to Company/ to eliminate namespace stutter 2026-04-03 22:20:04 +02:00
Darko Gjorgjijoski
6f095210d6 Consolidate Pdf controllers: 6 -> 1 DocumentPdfController
Merge InvoicePdfController, EstimatePdfController, PaymentPdfController
into DocumentPdfController with invoice(), estimate(), payment() methods.

Delete DownloadInvoicePdfController and DownloadPaymentPdfController
(dead code — not mapped in any routes).

Move DownloadReceiptController logic to ExpensesController::downloadReceipt()
(expense receipts, not PDF documents).
2026-04-03 22:16:20 +02:00
Darko Gjorgjijoski
b9e34ff25c Consolidate Company/Settings: 7 controllers -> 5
Merge CompanyCurrencyCheckTransactionsController into
CompanySettingsController as checkTransactions() method.

Merge UserSettingsController into UserProfileController as
showSettings() and updateSettings() methods — both operate on
the authenticated user (/me routes).
2026-04-03 22:11:16 +02:00
Darko Gjorgjijoski
8e7c48f532 Move BackupsController and UpdateController to Admin/ namespace directly
Remove single-file Backup/ and Update/ subdirectories. These controllers
now sit alongside CompaniesController, UsersController, etc. in Admin/.
2026-04-03 21:49:30 +02:00
Darko Gjorgjijoski
20ace694fe Fix UpdateController auth: use Bouncer ability instead of company owner check
ensureOwner() checked isOwner() which only verifies company ownership,
not super admin status. Replace with authorize('manage update app')
which uses the proper Bouncer ability gate for platform administration.
2026-04-03 21:45:40 +02:00
Darko Gjorgjijoski
3f5accc0f0 Consolidate Admin/Update: 8 controllers into 1 UpdateController
Merge 7 single-action pipeline controllers (checkVersion, download,
unzip, copy, delete, migrate, finish) into UpdateController with named
methods. Remove dead UpdateController that duplicated the same logic
but wasn't referenced in routes. Extract shared owner check into
private ensureOwner() helper. Route URLs unchanged.
2026-04-03 21:42:45 +02:00
Darko Gjorgjijoski
7bb6d9bcc3 Consolidate Admin/Settings: merge GetSettingsController + UpdateSettingsController into SettingsController 2026-04-03 21:21:13 +02:00
Darko Gjorgjijoski
142899cfd7 Consolidate Admin/Backup: merge ApiController and DownloadBackupController into BackupsController
Inline the respondSuccess() helper, add download() method. Remove the
unnecessary ApiController base class and DownloadBackupController.
2026-04-03 21:18:45 +02:00
Darko Gjorgjijoski
d505677a74 Consolidate Admin/Modules: 10 single-action controllers into 2
ModulesController: index, show, checkToken, enable, disable
ModuleInstallationController: download, upload, unzip, copy, complete
2026-04-03 21:16:18 +02:00
Darko Gjorgjijoski
e9ee74cd01 Add return types and typed parameters to remaining 10 models
Complete the type modernization across all models. Adds Builder-typed
$query parameters and return types to all scope methods, typed parameters
on accessors, and PHPDoc on scopePaginateData/scopeApplyFilters.

Models updated: Address, EstimateItem, Expense, ExpenseCategory,
InvoiceItem, Item, Note, Tax, TaxType, Unit.

5 models needed no changes (Country, Currency, ImpersonationLog,
Module, UserSetting) as they had no untyped public methods.
2026-04-03 20:53:41 +02:00
Darko Gjorgjijoski
0fa1aac748 Add return types, typed parameters, and PHPDoc to all model methods
Modernize all 16 models with missing type declarations:
- Return types on ~87 methods (string, bool, void, array, mixed, etc.)
- Typed parameters where missing
- PHPDoc blocks on non-obvious methods explaining their purpose

Models updated: Invoice, Estimate, Payment, User, Company, Customer,
RecurringInvoice, Setting, CompanySetting, FileDisk, Transaction,
EmailLog, ExchangeRateLog, PaymentMethod, CustomField, CustomFieldValue.
2026-04-03 20:46:26 +02:00
Darko Gjorgjijoski
c794f92932 Remove unused model constants
- Company: COMPANY_LEVEL, CUSTOMER_LEVEL (never referenced)
- Payment: all 5 PAYMENT_MODE_* constants (never referenced)
- Transaction: PENDING (never referenced)

RecurringInvoice constants (ACTIVE, ON_HOLD, NONE, COUNT, DATE) are kept
as they are used via hardcoded strings in services, factories, and migrations.
2026-04-03 20:39:21 +02:00
Darko Gjorgjijoski
c90dd1f2ac Remove dead model methods now handled by services
Remove createItem/updateItem from Item, createTransaction/
completeTransaction/failedTransaction from Transaction,
createCustomField/updateCustomField from CustomField, all business
methods from ExchangeRateProvider (CRUD + API checks + URL helpers),
and validateCredentials/createDisk/updateDisk/updateDefaultDisks/
setAsDefaultDisk from FileDisk.

All logic now lives in their respective service classes.
2026-04-03 20:32:02 +02:00
Darko Gjorgjijoski
85b62dfdf8 Refactor exchange rate providers into driver-based architecture
Replace duplicated switch/case blocks across 4 methods with a clean
abstract driver pattern:

- ExchangeRateDriver (abstract): defines getExchangeRate(),
  getSupportedCurrencies(), validateConnection()
- CurrencyFreakDriver, CurrencyLayerDriver, OpenExchangeRateDriver,
  CurrencyConverterDriver: concrete implementations
- ExchangeRateDriverFactory: resolves driver name to class, with
  register() method for module extensibility

Delete ExchangeRateProvidersTrait — all logic now lives in driver
classes and ExchangeRateProviderService. Adding a new exchange rate
provider only requires implementing ExchangeRateDriver and calling
ExchangeRateDriverFactory::register() in a module service provider.
2026-04-03 20:24:03 +02:00
Darko Gjorgjijoski
8f29e8f5de Extract business logic from remaining models to services
New services:
- ExchangeRateProviderService: CRUD, API status checks, currency converter
  URL resolution (extracted 122 lines from ExchangeRateProvider model)
- FileDiskService: create, update, setAsDefault, validateCredentials
  (extracted 97 lines from FileDisk model)
- ItemService: create/update with tax handling (extracted from Item model)
- TransactionService: create/complete/fail (extracted from Transaction model)
- CustomFieldService: create/update with slug generation (extracted from
  CustomField model)

Controllers updated to use constructor-injected services:
ExchangeRateProviderController, DiskController, ItemsController,
CustomFieldsController.
2026-04-03 19:32:37 +02:00
Darko Gjorgjijoski
ece6ce737b Rename Services/Installation to Services/Setup to match controllers 2026-04-03 19:23:32 +02:00
Darko Gjorgjijoski
00599b6943 Move Bouncer DefaultScope from app/Bouncer to app/Support/BouncerDefaultScope 2026-04-03 19:21:56 +02:00
Darko Gjorgjijoski
4f47db9258 Move Mobile/AuthController to Company/Auth and remove Mobile namespace
The Mobile namespace only contained an API auth controller (Sanctum token
login/logout/check) that is not mobile-specific. Relocated to
Company/Auth/AuthController alongside the other auth controllers.
2026-04-03 19:19:09 +02:00
Darko Gjorgjijoski
64c481e963 Rename controller namespaces: drop V1 prefix, clarify roles
V1/Admin     -> Company       (company-scoped controllers)
V1/SuperAdmin -> Admin        (platform-wide admin controllers)
V1/Customer  -> CustomerPortal (customer-facing portal)
V1/Installation -> Setup      (installation wizard)
V1/PDF       -> Pdf           (consistent casing)
V1/Modules   -> Modules       (drop V1 prefix)
V1/Webhook   -> Webhook       (drop V1 prefix)

The V1 prefix served no purpose - API versioning is in the route prefix
(/api/v1/), not the controller namespace. "Admin" was misleading for
company-scoped controllers. "SuperAdmin" is now simply "Admin" for
platform administration.
2026-04-03 19:15:20 +02:00
Darko Gjorgjijoski
0aaf0419c3 Reorganize Admin/General: 14 controllers down to 6
Move global reference data to SuperAdmin:
- CountriesController, CurrenciesController (not company-scoped)

Merge exchange rate operations into ExchangeRateProviderController:
- GetAllUsedCurrenciesController -> usedCurrenciesWithoutRate()
- BulkExchangeRateController -> bulkUpdate()

Consolidate single-action controllers:
- DateFormatsController + TimeFormatsController + TimezonesController -> FormatsController
- NextNumberController + NumberPlaceholdersController -> SerialNumberController
- SearchUsersController merged into SearchController::users()
2026-04-03 19:03:57 +02:00
Darko Gjorgjijoski
c0454613a8 Move customer stats logic from CustomerStatsController to CustomerService 2026-04-03 18:10:59 +02:00
Darko Gjorgjijoski
92872e7e1c Merge ShowReceiptController and UploadReceiptController into ExpensesController 2026-04-03 18:07:07 +02:00
Darko Gjorgjijoski
2191417151 Consolidate ExchangeRate single-action controllers into ExchangeRateProviderController
Merge 4 invocable controllers (GetActiveProvider, GetExchangeRate,
GetSupportedCurrencies, GetUsedCurrencies) as methods on the existing
resource controller: activeProvider(), getRate(), supportedCurrencies(),
usedCurrencies().
2026-04-03 18:03:24 +02:00
Darko Gjorgjijoski
5f389ea3b0 Consolidate single-action controllers into resource controllers
Merge 11 single-action controllers into their parent resource controllers:
- Invoice: send, sendPreview, clone, changeStatus -> InvoicesController
- Estimate: send, sendPreview, clone, convertToInvoice, changeStatus -> EstimatesController
- Payment: send, sendPreview -> PaymentsController

Extract clone and convert business logic from controllers into services:
- InvoiceService: add clone(), changeStatus()
- EstimateService: add clone(), convertToInvoice(), changeStatus()

Previously this logic was inlined in controllers (~80-90 lines each).
2026-04-03 17:55:46 +02:00
Darko Gjorgjijoski
f76f351244 Merge CompanyController into CompaniesController as show() method 2026-04-03 17:45:20 +02:00
Darko Gjorgjijoski
735eef6e9b Fix password update sending name and email to satisfy ProfileRequest validation 2026-04-03 17:42:14 +02:00
Darko Gjorgjijoski
4bfec37a53 Switch User Settings from horizontal tabs to sidebar layout
Match the Company Settings design pattern: sidebar navigation on desktop
with dropdown on mobile, child routes rendered via RouterView. Each tab
(General, Profile Photo, Security) is now a BaseSettingCard with its own
route under /admin/user-settings/{general,profile-photo,security}.
2026-04-03 17:41:18 +02:00
Darko Gjorgjijoski
1ca915a0a3 Split CompanyController and introduce standalone User Settings page
Backend:
- Extract user profile methods (show, update, uploadAvatar) from
  CompanyController into new UserProfileController
- CompanyController now only handles company concerns (updateCompany,
  uploadCompanyLogo)
- Remove Account Settings from setting_menu config

Frontend:
- New /admin/user-settings page with 3 tabs: General, Profile Photo,
  Security (password change)
- User dropdown now links to /admin/user-settings instead of
  /admin/settings/account-settings
- Settings sidebar defaults to Company Information as first item
- Remove old monolithic AccountSetting.vue
2026-04-03 17:35:41 +02:00
Darko Gjorgjijoski
6b5e4878fb Consolidate Admin Settings controllers: merge Get/Update pairs
Merge GetCompanySettingsController + UpdateCompanySettingsController into
CompanySettingsController with show() and update() methods.

Merge GetUserSettingsController + UpdateUserSettingsController into
UserSettingsController with show() and update() methods.

Absorb GetCompanyMailConfigurationController into
CompanyMailConfigurationController as getDefaultConfig() method.

Removes 5 single-action controllers, down to 4 Settings controllers.
2026-04-03 17:18:48 +02:00
Darko Gjorgjijoski
bbf46577dc Move global admin controllers from Admin to SuperAdmin namespace
Backup, Update, Modules, and global Settings controllers (mail config,
PDF config, disk management, global settings) are application-wide features
not scoped to a company. Move them from Admin/ to SuperAdmin/ to match the
v3.0 UI structure where these live under Administration.

Company-scoped settings controllers remain in Admin/Settings/.
2026-04-03 16:52:18 +02:00
Darko Gjorgjijoski
de06c335ac Remove dead code from app/Http: unused middleware, requests, and resources
Delete 23 unused files:
- AdminMiddleware (never registered, SuperAdminMiddleware used instead)
- 4 form requests with no controller references
- AbilityResource/Collection and ExchangeRateLogResource/Collection (zero usage)
- Customer/RecurringInvoiceResource and Collection (no controller or nested reference)
- 10 Customer Collection classes whose Resources are only used via new, never ::collection()
2026-04-03 16:41:33 +02:00
Darko Gjorgjijoski
62f31960ab Move CustomPathGenerator from app/Generators to app/Support 2026-04-03 16:21:36 +02:00
Darko Gjorgjijoski
129028518d Consolidate PDF classes under app/Services/Pdf with consistent naming
Split PDFService.php (3 classes + 2 interfaces in one file) into separate
files. Move GotenbergPDFDriver from app/Services/PDFDrivers/ into
app/Services/Pdf/. Normalize casing from ALL-CAPS PDF to Pdf throughout:
facade, provider, service, driver factory, and Gotenberg driver.

Fix PaymentService using Barryvdh DomPDF facade directly instead of the
app's PDF facade (bypassed the driver factory). Report controllers also
updated to use the app facade.
2026-04-03 16:18:25 +02:00
Darko Gjorgjijoski
e0b8b86e06 Rename SerialNumberFormatter to SerialNumberService for consistency 2026-04-03 16:09:22 +02:00
Darko Gjorgjijoski
e208e3ba56 Move Hashids classes from app/Hashids to app/Services/Hashids 2026-04-03 15:41:05 +02:00
Darko Gjorgjijoski
0ce88ab817 Remove app/Space folder and extract model business logic into services
Relocate all 14 files from the catch-all app/Space namespace into proper
locations: data providers to Support/Formatters, installation utilities to
Services/Installation, PDF utils to Services/Pdf, module/update classes to
Services/Module and Services/Update, SiteApi trait to Traits, and helpers
to Support.

Extract ~1,400 lines of business logic from 8 fat models (Invoice, Payment,
Estimate, RecurringInvoice, Company, Customer, Expense, User) into 9 new
service classes with constructor injection. Controllers now depend on
services instead of calling static model methods. Shared item/tax creation
logic consolidated into DocumentItemService.
2026-04-03 15:37:22 +02:00
Darko Gjorgjijoski
23ff69026e Merge branch 'master' into v3.0 2026-04-03 14:36:24 +02:00
Darko Gjorgjijoski
55416bc633 Improve copyright handling 2026-04-03 13:34:33 +02:00
Darko Gjorgjijoski
c469f64c79 Remove unused CompanySetting defaults from company setup
Remove invoice_auto_generate, payment_auto_generate, estimate_auto_generate
(defined but never read anywhere in the codebase) and duplicate save_pdf_to_disk
entries (now a global Setting, not per-company).
2026-04-03 10:36:48 +02:00
Darko Gjorgjijoski
9432da467e Add super-admin Administration section and restructure global vs company settings
- Add Administration sidebar section (super-admin only) with Companies, Users, and Global Settings pages
- Add super-admin middleware, controllers, and API routes under /api/v1/super-admin/
- Allow super-admins to manage all companies and users across tenants
- Add user impersonation with short-lived tokens, audit logging, and UI banner
- Move system-level settings (Mail, PDF, Backup, Update, File Disk) from per-company to Administration > Global Settings
- Convert save_pdf_to_disk from CompanySetting to global Setting
- Add per-company mail configuration overrides (optional, falls back to global)
- Add CompanyMailConfigService to apply company mail config before sending emails
2026-04-03 10:35:40 +02:00
1148 changed files with 65208 additions and 59139 deletions

5
.claude/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}

View File

@@ -21,3 +21,8 @@ TRUSTED_PROXIES="*"
# Dompdf: keep false so untrusted HTML in PDF notes cannot trigger outbound requests (SSRF).
# Set true only if you fully trust all PDF HTML and need remote images/CSS.
DOMPDF_ENABLE_REMOTE=false
# InvoiceShelf marketplace and updater base URL.
# Defaults to https://invoiceshelf.com. Override to point at a local website
# checkout for development:
# INVOICESHELF_BASE_URL=http://invoiceshelf-website.test

18
.github/release.yml vendored Normal file
View File

@@ -0,0 +1,18 @@
changelog:
categories:
- title: New Features
labels:
- enhancement
- feature
- title: Bug Fixes
labels:
- bug
- fix
- title: Maintenance
labels:
- chore
- dependencies
- ci
- title: Other Changes
labels:
- "*"

96
.github/workflows/release.yaml vendored Normal file
View File

@@ -0,0 +1,96 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
name: Build & Release
runs-on: ubuntu-latest
env:
extensions: bcmath, curl, dom, gd, imagick, json, libxml, mbstring, pcntl, pdo, pdo_mysql, zip
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.4
extensions: ${{ env.extensions }}
tools: composer
- name: Install Composer dependencies
run: composer install --no-dev --optimize-autoloader --no-interaction
- name: Use Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
- name: Install npm dependencies
run: npm ci
- name: Build frontend
run: npm run build
- name: Prepare release directory
run: |
mkdir -p /tmp/InvoiceShelf/public
cp -r app /tmp/InvoiceShelf/
cp -r bootstrap /tmp/InvoiceShelf/
cp -r config /tmp/InvoiceShelf/
cp -r database /tmp/InvoiceShelf/
cp -r public/build /tmp/InvoiceShelf/public/
cp -r public/favicons /tmp/InvoiceShelf/public/
cp public/.htaccess /tmp/InvoiceShelf/public/
cp public/index.php /tmp/InvoiceShelf/public/
cp public/robots.txt /tmp/InvoiceShelf/public/
cp public/web.config /tmp/InvoiceShelf/public/
cp -r resources /tmp/InvoiceShelf/
cp -r lang /tmp/InvoiceShelf/
cp -r routes /tmp/InvoiceShelf/
cp -r storage /tmp/InvoiceShelf/
cp -r vendor /tmp/InvoiceShelf/ 2>/dev/null || true
cp -r scripts /tmp/InvoiceShelf/
cp version.md /tmp/InvoiceShelf/
cp .env.example /tmp/InvoiceShelf/
cp artisan /tmp/InvoiceShelf/
cp composer.json /tmp/InvoiceShelf/
cp composer.lock /tmp/InvoiceShelf/
cp LICENSE /tmp/InvoiceShelf/
cp readme.md /tmp/InvoiceShelf/
cp SECURITY.md /tmp/InvoiceShelf/
cp server.php /tmp/InvoiceShelf/
# Clean up runtime artifacts
find /tmp/InvoiceShelf -wholename '*/[Tt]ests/*' -delete
find /tmp/InvoiceShelf -wholename '*/[Tt]est/*' -delete
rm -rf /tmp/InvoiceShelf/storage/framework/cache/data/* 2>/dev/null || true
rm -f /tmp/InvoiceShelf/storage/framework/sessions/* 2>/dev/null || true
rm -f /tmp/InvoiceShelf/storage/framework/views/* 2>/dev/null || true
rm -f /tmp/InvoiceShelf/storage/logs/* 2>/dev/null || true
touch /tmp/InvoiceShelf/storage/logs/laravel.log
- name: Generate manifest
run: php scripts/generate-manifest.php /tmp/InvoiceShelf
- name: Create zip
working-directory: /tmp
run: zip -r InvoiceShelf.zip InvoiceShelf/
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: /tmp/InvoiceShelf.zip
generate_release_notes: true
make_latest: true

View File

@@ -64,8 +64,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -73,7 +73,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\PDFService::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,
@@ -147,8 +147,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -156,7 +156,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\PDFService::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,
@@ -230,8 +230,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -239,7 +239,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\PDFService::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,
@@ -313,8 +313,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -322,7 +322,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\PDFService::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,
@@ -396,8 +396,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -405,7 +405,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\PDFService::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,
@@ -479,8 +479,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -488,7 +488,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\PDFService::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,
@@ -562,8 +562,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -571,7 +571,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\PDFService::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,
@@ -645,8 +645,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -654,7 +654,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\PDFService::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,
@@ -728,8 +728,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -737,7 +737,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\PDFService::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,
@@ -811,8 +811,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -820,7 +820,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\PDFService::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,
@@ -894,8 +894,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -903,7 +903,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\PDFService::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,

View File

@@ -59,6 +59,27 @@ This project has domain-specific skills available. You MUST activate the relevan
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== invoiceshelf rules ===
# InvoiceShelf Architecture
## Service Pattern (Required)
All business logic must live in Service classes (`app/Services/`). This is mandatory — do not put business logic in Models or Controllers.
- **Controllers** are thin: authorize, call the service, return a response.
- **Models** only contain relationships, scopes, accessors, mutators, and constants.
- **Services** are injected into controllers via constructor injection.
- Check existing services in `app/Services/` for patterns before creating new ones.
## Testing (TDD)
InvoiceShelf follows TDD development style. Every new feature or bug fix must have tests.
- **Feature tests** (`tests/Feature/`) — test API routes end-to-end (HTTP requests, responses, database assertions). These are the primary test type.
- **Unit tests** (`tests/Unit/`) — test service classes and business logic in isolation.
- Write tests before or alongside implementation, not after.
## Roles
- **`super admin`** — global platform admin role (unscoped, manages all companies)
- **`owner`** — company-level admin role (scoped to a specific company via Bouncer, full access to that company)
=== boost rules ===
# Laravel Boost

View File

@@ -47,33 +47,82 @@ Three guards: `web` (session), `api` (Sanctum tokens for `/api/v1/`), `customer`
- **Web**: `routes/web.php` serves PDF endpoints, auth pages, and catch-all SPA routes (`/admin/{vue?}`, `/{company:slug}/customer/{vue?}`)
### Frontend
- Entry point: `resources/scripts/main.js`
- Vue Router: `resources/scripts/admin/admin-router.js` (admin), `resources/scripts/customer/customer-router.js` (customer portal)
- State: Pinia stores in `resources/scripts/admin/stores/`
- Path aliases: `@` = `resources/`, `$fonts`, `$images` for static assets
- Vite dev server expects `invoiceshelf.test` hostname
- Vue 3 + TypeScript + Pinia + vue-router + Tailwind v4 (`@tailwindcss/vite`)
- Entry point: `resources/scripts/main.ts` (single Vite input)
- Feature-folder layout under `resources/scripts/features/{admin,auth,company,customer-portal,...}` — each feature owns its own `routes.ts`, `views/`, `components/`
- Shared layers: `resources/scripts/{api,stores,components,composables,layouts,plugins,utils,types,config}`
- Path aliases: `@``resources/` (so most imports look like `@/scripts/api/client`, `@/scripts/stores/global.store`); `$fonts``resources/static/fonts`; `$images``resources/static/img`. There is no `@v2` alias — that was retired when the legacy v1 SPA was deleted.
- i18n: `lang/*.json` are dynamically imported by `resources/scripts/plugins/i18n.ts`. Locale-code → filename mismatches (e.g. `pt_BR``pt-br.json`) live in `LOCALE_FILE_MAP`. English is statically bundled; other locales lazy-load. Only edit `lang/en.json` directly — other locales are Crowdin-sourced.
- Vite dev server expects the `invoiceshelf.test` hostname (configured in `vite.config.js`)
### CSS Theme Tokens
The styling system uses **Tailwind v4 with CSS custom properties as the source of truth** — colors are not configured in JS, they live in CSS and are exposed to Tailwind via the `@theme` directive. Two files own this:
1. **`resources/css/themes.css`** — defines every color as a CSS custom property on `:root` (light) and `[data-theme="dark"]` (dark). This is where you change actual values.
2. **`resources/css/invoiceshelf.css`** — has an `@theme inline { ... }` block that **registers** each custom property as a Tailwind theme token (e.g. `--color-heading: var(--color-heading);`), making it available as utility classes (`bg-heading`, `text-heading`, `border-heading`, etc.). The block also uses the legacy `@theme { --spacing-88: 22rem; --font-base: Poppins, sans-serif; }` for non-color tokens.
**Token categories defined today:**
- `primary-{50…950}` — brand color scale
- `surface`, `surface-secondary`, `surface-tertiary`, `surface-muted` — background depth tiers
- `heading`, `body`, `muted`, `subtle` — text emphasis tiers
- `line-{light,default,strong}` — borders
- `hover`, `hover-strong` — hover backgrounds
- `header-from`, `header-to` — fixed header gradient stops (not dark-mode-aware)
- `btn-primary`, `btn-primary-hover` — button colors (fixed, always bold)
- `status-{yellow,green,blue,red,purple}` — status badge text colors
- `alert-{warning,error,success}-{bg,text}` — alert variants
**Dark mode** is toggled via the `[data-theme="dark"]` attribute on the `<html>` element. The same custom-property names get redefined under that selector — components do **not** need `dark:` variants or conditional logic, they just reference the semantic tokens and the right value is picked up automatically.
**Adding a new color token is a two-step ritual:**
1. Add the custom property to **both** `:root` and `[data-theme="dark"]` in `themes.css`
2. Add a matching `--color-X: var(--color-X);` line inside the `@theme inline` block in `invoiceshelf.css`
After that the token is usable as `bg-X` / `text-X` / `border-X` in Vue templates and as `var(--color-X)` in raw CSS. Skip step 2 and the value exists at the CSS level but Tailwind utility classes won't be generated.
**Convention — never hardcode hex/rgb values in components.** Use the semantic tokens: `text-heading` not `text-gray-900`, `bg-surface` not `bg-white`, `border-line-default` not `border-gray-300`. Hardcoded values won't follow dark-mode flips and will diverge from the rest of the app over time. There are **no exceptions** in the project — even the auth pages (which sit outside the admin chrome) use the same `bg-surface` / `text-heading` / `border-line-default` vocabulary as `BaseCard`, just composed differently.
### Backend Patterns
- **Authorization**: Silber/Bouncer with policies in `app/Policies/`. Controllers use `$this->authorize()`.
- **Validation**: Form Request classes, never inline validation
- **API responses**: Eloquent API Resources in `app/Http/Resources/`
- **PDF generation**: DomPDF (`GeneratesPdfTrait`) or Gotenberg
- **Email**: Mailable classes with `EmailLog` tracking
- **File storage**: Spatie MediaLibrary, supports local/S3/Dropbox
- **Serial numbers**: `SerialNumberFormatter` service
- **PDF generation**: Pluggable driver — `dompdf` (default, via `GeneratesPdfTrait`) or `gotenberg` (headless Chromium). Driver chosen per company through the **PDF Generation** admin settings page.
- **Email**: Mailable classes with `EmailLog` tracking. Mail driver is configurable globally and may be overridden per-company.
- **File storage**: Spatie MediaLibrary backed by the **FileDisk** model — admins create named disk entries (local / S3 / Dropbox / DigitalOcean Spaces) and assign them to purposes (`media_storage`, `pdf_storage`, `backup_storage`) in **Admin → File Disks → Disk Assignments**. New uploads go to the assigned disk; existing files stay where they were and require `php artisan media:secure` to migrate.
- **Serial numbers**: `SerialNumberService`
- **Company settings**: `CompanySetting` model (key-value per company)
- **User settings**: User-level preferences (notably `language`) stored as JSON via `setSettings()`. The sentinel value `'default'` means "inherit the company-level setting" — used for the per-user language preference so promoting/inviting members doesn't freeze a copy of the inviter's language.
### PDF Font System
PDFs ship with bundled **Noto Sans** (Latin / Greek / Cyrillic) as the default face. Non-Latin scripts come from on-demand **Font Packages** managed in **Admin → Font Packages** and defined in `FontService::FONT_PACKAGES` (`app/Services/FontService.php`). Currently shipped packages: `noto-sans` (bundled, marker only), `noto-sans-{sc,tc,jp,kr}` (CJK), `noto-sans-hebrew`, `noto-naskh-arabic` (covers `ar`/`fa`/`ur`), `noto-sans-devanagari` (`hi`), `sarabun` (Thai). `GeneratesPdfTrait::ensureFontsForLocale()` synchronously installs the matching package on the first PDF render for a given company language.
Two non-obvious constraints when extending the font system:
1. **dompdf's PHP-Font-Lib does not parse variable fonts** (`fvar`/`gvar` tables). Any new package must source **static TTF** files — Google Fonts' main repo ships variable fonts and produces empty boxes. Reliable static-TTF sources used today: `openmaptiles/fonts` for non-CJK Noto scripts, `life888888/cjk-fonts-ttf` for the CJK packages, `google/fonts/ofl/sarabun` for Thai.
2. **dompdf does not glyph-fall-back through the `font-family` chain** — it uses the *first* font for ALL characters. So locale-specific packages must be the **primary** font for that locale, not a fallback. Selection happens in `FontService::getFontFamilyForLocale()`. This is also why a Latin-locale company with a Hebrew customer name will still render boxes for the Hebrew text — solving that needs Gotenberg or a custom mid-render font-switching pass.
The bundled NotoSans is also surfaced as a `bundled: true` package entry (no download URL, files served from `resources/static/fonts/` instead of `storage/fonts/`) so it appears alongside the on-demand packages in the admin UI with a "Bundled" pill instead of an Install button.
### Database
Supports MySQL, PostgreSQL, and SQLite. Prefer Eloquent over raw queries. Use `Model::query()` instead of `DB::`. Use eager loading to prevent N+1 queries.
### Service Pattern
All business logic must live in Service classes (`app/Services/`), not in Models or Controllers. Controllers are thin — they authorize, call the service, and return a response. Models only contain relationships, scopes, accessors, mutators, and constants. Services are injected via constructor injection.
### Testing (TDD)
InvoiceShelf follows TDD development style:
- **Feature tests** (`tests/Feature/`) — test API routes end-to-end (HTTP requests, responses, database assertions)
- **Unit tests** (`tests/Unit/`) — test service classes and business logic in isolation
- Write tests before or alongside implementation. Every new feature or bug fix must have tests.
## Code Conventions
- PHP: snake_case, constructor property promotion, explicit return types, PHPDoc blocks over inline comments
- JS: camelCase
- TS / Vue: camelCase, `<script setup lang="ts">`, prefer Composition API + Pinia stores over component-local state for anything cross-cutting
- Always check sibling files for patterns before creating new ones
- Use `config()` helper, never `env()` outside config files
- Every change must have tests (feature tests preferred over unit tests)
- Every change must have tests
- Run `vendor/bin/pint --dirty --format agent` after modifying PHP files
- After editing `lang/en.json` or any file under `resources/scripts/`, rebuild via `npm run build` — the bundled chunks (including locale chunks) are content-hashed by Vite, so the browser will pick them up on hard refresh
## CI Pipeline

68
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,68 @@
# Contributing to InvoiceShelf
Thank you for your interest in contributing to InvoiceShelf! We welcome contributions from the community and appreciate your effort to improve this project.
## How to Contribute
1. **Fork the repository** and create a new branch from `master`
2. **Make your changes** — follow the existing code style and conventions
3. **Write tests** if applicable
4. **Run the test suite** to ensure nothing is broken
5. **Submit a Pull Request** with a clear description of the changes
## Development Setup
Please refer to the [README](README.md) for instructions on setting up the development environment.
## Code Style
- **PHP**: Follow PSR-12 standards. Run `vendor/bin/pint --dirty` before committing
- **TypeScript/Vue**: Follow the existing patterns in `resources/scripts/`
- **Tests**: Every new feature or bug fix should include tests
## Contributor License Agreement (CLA)
By submitting a pull request, patch, commit, or any other contribution (collectively, "Contribution") to this repository, you agree to the following terms:
### Grant of Rights
You hereby grant to **Ideologix Media Dooel** (the maintainer and owner of InvoiceShelf), and to recipients of software distributed by Ideologix Media Dooel, a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable license to:
- Reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute your Contribution and any derivative works thereof
- Use your Contribution for any purpose, including but not limited to incorporating it into InvoiceShelf or any other product or service
- Relicense your Contribution under any license, including proprietary licenses
### Copyright Assignment
You agree that your Contribution, once merged, becomes part of the InvoiceShelf project and that **Ideologix Media Dooel** retains full rights to the combined work, including the right to:
- Offer the software under dual licensing (open-source and commercial)
- Enforce the license terms against third parties
- Make licensing decisions without requiring additional permission from contributors
### Representations
By submitting a Contribution, you represent that:
1. You have the legal right to grant the above license
2. Your Contribution is your original work, or you have sufficient rights to submit it
3. Your Contribution does not violate any third-party intellectual property rights
4. You understand that your Contribution is provided voluntarily and is not confidential
### Scope
This agreement applies to all Contributions made to any repository owned by InvoiceShelf / Ideologix Media Dooel, including but not limited to code, documentation, translations, and design assets.
## Reporting Issues
- Use [GitHub Issues](https://github.com/InvoiceShelf/InvoiceShelf/issues) for bug reports and feature requests
- Include steps to reproduce for bug reports
- Check existing issues before creating a new one
## Questions?
If you have questions about contributing, feel free to open a discussion on GitHub.
---
*InvoiceShelf is maintained by [Ideologix Media Dooel](https://ideologix.com/) and licensed under the [AGPL-3.0 License](LICENSE).*

View File

@@ -29,6 +29,7 @@ dist-gen: clean composer npm-build
@cp -r routes InvoiceShelf
@cp -r storage InvoiceShelf
@cp -r vendor InvoiceShelf 2> /dev/null || true
@cp -r scripts InvoiceShelf
@cp -r version.md InvoiceShelf
@cp -r .env.example InvoiceShelf
@cp -r artisan InvoiceShelf
@@ -47,6 +48,7 @@ dist-clean: dist-gen
@rm InvoiceShelf/storage/framework/sessions/* 2> /dev/null || true
@rm InvoiceShelf/storage/framework/views/* 2> /dev/null || true
@rm InvoiceShelf/storage/logs/* 2> /dev/null || true
@php scripts/generate-manifest.php InvoiceShelf
dist: dist-clean
@zip -r InvoiceShelf.zip InvoiceShelf

View File

@@ -2,7 +2,7 @@
namespace App\Console\Commands;
use App\Space\PdfTemplateUtils;
use App\Services\Pdf\PdfTemplateUtils;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;

View File

@@ -2,7 +2,7 @@
namespace App\Console\Commands;
use App\Space\ModuleInstaller;
use App\Services\Module\ModuleInstaller;
use Illuminate\Console\Command;
class InstallModuleCommand extends Command

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Console\Commands;
use App\Models\FileDisk;
use App\Models\Setting;
use App\Services\FileDiskService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
class MigrateMediaToPrivateDisk extends Command
{
protected $signature = 'media:secure {--dry-run : Show what would be moved without moving}';
protected $description = 'Move sensitive media files (receipts) from the public disk to the private disk';
public function handle(): int
{
$targetDisk = $this->resolveTargetDisk();
if (! $targetDisk) {
$this->error('No target disk found. Set a default FileDisk or configure media_disk_id setting.');
return self::FAILURE;
}
$targetDiskName = app(FileDiskService::class)->registerDisk($targetDisk);
$targetRoot = config('filesystems.disks.'.$targetDiskName.'.root');
if (! $targetRoot) {
$this->error('Could not resolve target disk root path.');
return self::FAILURE;
}
$records = DB::table('media')
->where('disk', 'public')
->where(function ($query) {
$query->where('collection_name', 'receipts');
})
->get();
if ($records->isEmpty()) {
$this->info('No media files to migrate.');
return self::SUCCESS;
}
$this->info('Found '.$records->count().' file(s) to migrate.');
if ($this->option('dry-run')) {
foreach ($records as $record) {
$this->line(" Would move: media/{$record->id}/{$record->file_name}");
}
return self::SUCCESS;
}
$moved = 0;
$skipped = 0;
$publicMediaRoot = public_path('media');
$bar = $this->output->createProgressBar($records->count());
$bar->start();
foreach ($records as $record) {
$relativePath = $record->id.DIRECTORY_SEPARATOR.$record->file_name;
$sourcePath = $publicMediaRoot.DIRECTORY_SEPARATOR.$relativePath;
$destPath = $targetRoot.DIRECTORY_SEPARATOR.$relativePath;
if (! file_exists($sourcePath)) {
$skipped++;
$bar->advance();
continue;
}
$destDir = dirname($destPath);
if (! File::isDirectory($destDir)) {
File::makeDirectory($destDir, 0755, true);
}
File::move($sourcePath, $destPath);
DB::table('media')
->where('id', $record->id)
->update(['disk' => $targetDiskName]);
$moved++;
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->info("Done. Moved: {$moved}, Skipped (missing): {$skipped}");
// Clean up empty directories in public/media
$this->cleanEmptyDirectories($publicMediaRoot);
return self::SUCCESS;
}
private function resolveTargetDisk(): ?FileDisk
{
$mediaDiskId = Setting::getSetting('media_disk_id');
if ($mediaDiskId) {
return FileDisk::find($mediaDiskId);
}
return FileDisk::where('set_as_default', true)->first();
}
private function cleanEmptyDirectories(string $path): void
{
if (! File::isDirectory($path)) {
return;
}
$directories = File::directories($path);
foreach ($directories as $dir) {
$this->cleanEmptyDirectories($dir);
if (count(File::allFiles($dir)) === 0 && count(File::directories($dir)) === 0) {
File::deleteDirectory($dir);
}
}
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Console\Commands;
use App\Space\Updater;
use App\Services\Update\Updater;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;

View File

@@ -7,7 +7,7 @@ use Illuminate\Support\Facades\Facade;
/**
* @method static \Psr\Http\Message\ResponseInterface loadView(string $template)
*/
class PDF extends Facade
class Pdf extends Facade
{
protected static function getFacadeAccessor()
{

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
class AdminDashboardController extends Controller
{
public function index(): JsonResponse
{
$version = preg_replace('~[\r\n]+~', '', File::get(base_path('version.md')));
$dbDriver = config('database.default');
$dbVersion = $this->getDatabaseVersion($dbDriver);
return response()->json([
'app_version' => $version,
'php_version' => phpversion(),
'database' => [
'driver' => $dbDriver,
'version' => $dbVersion,
],
'counts' => [
'companies' => Company::count(),
'users' => User::count(),
],
]);
}
private function getDatabaseVersion(string $driver): ?string
{
try {
return match ($driver) {
'mysql' => DB::selectOne('SELECT VERSION() as version')?->version,
'pgsql' => DB::selectOne('SHOW server_version')?->server_version,
'sqlite' => DB::selectOne('SELECT sqlite_version() as version')?->version,
default => null,
};
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Jobs\CreateBackupJob;
use App\Rules\Backup\PathToZip;
use App\Services\Backup\BackupService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Spatie\Backup\BackupDestination\Backup;
use Spatie\Backup\Helpers\Format;
use Symfony\Component\HttpFoundation\StreamedResponse;
class BackupsController extends Controller
{
public function __construct(
private readonly BackupService $backupService,
) {}
public function index(Request $request): JsonResponse
{
$this->authorize('manage backups');
try {
$destination = $this->backupService->getDestination($request->file_disk_id);
$backups = $destination
->backups()
->map(function (Backup $backup) {
return [
'path' => $backup->path(),
'created_at' => $backup->date()->format('Y-m-d H:i:s'),
'size' => Format::humanReadableSize($backup->sizeInBytes()),
];
})
->toArray();
return response()->json([
'backups' => $backups,
]);
} catch (\Exception $e) {
return response()->json([
'backups' => [],
'error' => 'invalid_disk_credentials',
'error_message' => $e->getMessage(),
]);
}
}
public function store(Request $request): JsonResponse
{
$this->authorize('manage backups');
$data = $request->all();
dispatch(new CreateBackupJob($data))->onQueue(config('backup.queue.name'));
return response()->json(['success' => true]);
}
public function destroy($disk, Request $request): JsonResponse
{
$this->authorize('manage backups');
$validated = $request->validate([
'path' => ['required', new PathToZip],
]);
$destination = $this->backupService->getDestination($request->file_disk_id);
$destination
->backups()
->first(function (Backup $backup) use ($validated) {
return $backup->path() === $validated['path'];
})
->delete();
return response()->json(['success' => true]);
}
public function download(Request $request): Response|StreamedResponse
{
$this->authorize('manage backups');
$validated = $request->validate([
'path' => ['required', new PathToZip],
]);
$destination = $this->backupService->getDestination($request->file_disk_id);
$backup = $destination->backups()->first(function (Backup $backup) use ($validated) {
return $backup->path() === $validated['path'];
});
if (! $backup) {
return response('Backup not found', 422);
}
$fileName = pathinfo($backup->path(), PATHINFO_BASENAME);
return response()->stream(function () use ($backup) {
$stream = $backup->stream();
fpassthru($stream);
if (is_resource($stream)) {
fclose($stream);
}
}, 200, [
'Cache-Control' => 'must-revalidate, post-check=0, pre-check=0',
'Content-Type' => 'application/zip',
'Content-Length' => $backup->sizeInBytes(),
'Content-Disposition' => 'attachment; filename="'.$fileName.'"',
'Pragma' => 'public',
]);
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Facades\Hashids;
use App\Http\Controllers\Controller;
use App\Http\Requests\AdminCompanyUpdateRequest;
use App\Http\Requests\CompaniesRequest;
use App\Http\Resources\CompanyResource;
use App\Models\Company;
use App\Services\CompanyService;
use Illuminate\Http\Request;
use Silber\Bouncer\BouncerFacade;
class CompaniesController extends Controller
{
public function __construct(
private readonly CompanyService $companyService,
) {}
public function index(Request $request)
{
$companies = Company::query()
->with(['owner', 'address'])
->when($request->has('search'), function ($query) use ($request) {
$query->where('name', 'like', '%'.$request->search.'%');
})
->when($request->has('orderByField') && $request->has('orderBy'), function ($query) use ($request) {
$query->orderBy($request->orderByField, $request->orderBy);
}, function ($query) {
$query->orderBy('name', 'asc');
})
->paginate($request->input('limit', 10));
return CompanyResource::collection($companies);
}
public function show(Company $company)
{
$company->load(['owner', 'address']);
return new CompanyResource($company);
}
public function update(AdminCompanyUpdateRequest $request, Company $company)
{
$company->update([
'name' => $request->name,
'vat_id' => $request->vat_id,
'tax_id' => $request->tax_id,
'owner_id' => $request->owner_id,
]);
if ($request->has('address')) {
$company->address()->updateOrCreate(
['company_id' => $company->id],
$request->address,
);
}
$company->load(['owner', 'address']);
return new CompanyResource($company);
}
public function store(CompaniesRequest $request)
{
$this->authorize('create company');
$user = $request->user();
$company = Company::create($request->getCompanyPayload());
$company->unique_hash = Hashids::connection(Company::class)->encode($company->id);
$company->save();
$this->companyService->setupDefaults($company);
$user->companies()->attach($company->id);
BouncerFacade::scope()->to($company->id);
$user->assign('owner');
if ($request->address) {
$company->address()->create($request->address);
}
return new CompanyResource($company);
}
public function destroy(Request $request)
{
$company = Company::find($request->header('company'));
$this->authorize('delete company', $company);
$user = $request->user();
if ($request->name !== $company->name) {
return respondJson('company_name_must_match_with_given_name', 'Company name must match with given name');
}
$this->companyService->delete($company, $user);
return response()->json([
'success' => true,
]);
}
public function userCompanies(Request $request)
{
$companies = $request->user()->companies;
return CompanyResource::collection($companies);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\General;
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Resources\CountryResource;

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Resources\CurrencyResource;
use App\Services\CurrencyService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class CurrenciesController extends Controller
{
public function __construct(
private readonly CurrencyService $currencyService,
) {}
public function __invoke(Request $request): AnonymousResourceCollection
{
$currencies = $this->currencyService->getAllWithCommonFirst();
return CurrencyResource::collection($currencies);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\FontService;
use Illuminate\Http\JsonResponse;
class FontController extends Controller
{
public function __construct(
private readonly FontService $fontService,
) {}
public function status(): JsonResponse
{
$this->authorize('manage settings');
return response()->json([
'packages' => $this->fontService->getPackageStatuses(),
]);
}
public function install(string $package): JsonResponse
{
$this->authorize('manage settings');
if (! isset(FontService::FONT_PACKAGES[$package])) {
return response()->json(['error' => 'Unknown font package'], 404);
}
$pkg = FontService::FONT_PACKAGES[$package];
if ($this->fontService->isInstalled($pkg)) {
return response()->json(['success' => true, 'message' => 'Already installed']);
}
try {
$this->fontService->downloadPackage($pkg);
return response()->json([
'success' => true,
'installed' => true,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Admin\Modules;
use App\Http\Controllers\Controller;
use App\Http\Requests\UnzipUpdateRequest;
use App\Http\Requests\UploadModuleRequest;
use App\Services\Module\ModuleInstaller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ModuleInstallationController extends Controller
{
public function download(Request $request): JsonResponse
{
$this->authorize('manage modules');
$response = ModuleInstaller::download(
(string) $request->slug,
(string) $request->version,
$request->checksum_sha256 ? (string) $request->checksum_sha256 : null,
);
return response()->json($response);
}
public function upload(UploadModuleRequest $request): JsonResponse
{
$this->authorize('manage modules');
$response = ModuleInstaller::upload($request);
return response()->json($response);
}
public function unzip(UnzipUpdateRequest $request): JsonResponse
{
$this->authorize('manage modules');
$path = ModuleInstaller::unzip($request->module_name ?? $request->module, $request->path);
return response()->json([
'success' => true,
'path' => $path,
]);
}
public function copy(Request $request): JsonResponse
{
$this->authorize('manage modules');
$response = ModuleInstaller::copyFiles($request->module_name ?? $request->module, $request->path);
return response()->json([
'success' => $response,
]);
}
public function complete(Request $request): JsonResponse
{
$this->authorize('manage modules');
$response = ModuleInstaller::complete($request->module_name ?? $request->module, $request->version);
return response()->json([
'success' => $response,
]);
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Admin\Modules;
use App\Events\ModuleDisabledEvent;
use App\Events\ModuleEnabledEvent;
use App\Http\Controllers\Controller;
use App\Http\Resources\ModuleResource;
use App\Models\Module as ModelsModule;
use App\Services\Module\ModuleInstaller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Nwidart\Modules\Facades\Module;
class ModulesController extends Controller
{
public function index(Request $request)
{
$this->authorize('manage modules');
$response = ModuleInstaller::getModules();
if (($response['status'] ?? 0) !== 200 || ! isset($response['body']->modules)) {
return response()->json(['error' => 'marketplace_unavailable'], 503);
}
return ModuleResource::collection(collect($response['body']->modules));
}
public function show(Request $request, string $module)
{
$this->authorize('manage modules');
$response = ModuleInstaller::getModule($module);
if (($response['status'] ?? 0) === 404) {
return response()->json(['error' => 'not_found'], 404);
}
if (($response['status'] ?? 0) !== 200 || ! isset($response['body']->data)) {
return response()->json(['error' => 'marketplace_unavailable'], 503);
}
return (new ModuleResource($response['body']->data))
->additional(['meta' => [
'modules' => ModuleResource::collection(
collect($response['body']->meta->modules ?? [])
),
]]);
}
public function checkToken(Request $request): JsonResponse
{
$this->authorize('manage modules');
return ModuleInstaller::checkToken($request->api_token);
}
public function enable(Request $request, string $module): JsonResponse
{
$this->authorize('manage modules');
$module = ModelsModule::where('name', $module)->first();
$module->update(['enabled' => true]);
$installedModule = Module::find($module->name);
$installedModule->enable();
ModuleEnabledEvent::dispatch($module);
return response()->json(['success' => true]);
}
public function disable(Request $request, string $module): JsonResponse
{
$this->authorize('manage modules');
$module = ModelsModule::where('name', $module)->first();
$module->update(['enabled' => false]);
$installedModule = Module::find($module->name);
$installedModule->disable();
ModuleDisabledEvent::dispatch($module);
return response()->json(['success' => true]);
}
}

View File

@@ -0,0 +1,268 @@
<?php
namespace App\Http\Controllers\Admin\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\DiskEnvironmentRequest;
use App\Http\Resources\FileDiskResource;
use App\Models\FileDisk;
use App\Models\Setting;
use App\Services\FileDiskService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
class DiskController extends Controller
{
public function __construct(
private readonly FileDiskService $fileDiskService,
) {}
/**
* @throws AuthorizationException
* @throws AuthorizationException
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('manage file disk');
$limit = $request->has('limit') ? $request->limit : 5;
$disks = FileDisk::applyFilters($request->all())
->latest()
->paginateData($limit);
return FileDiskResource::collection($disks);
}
/**
* @return JsonResponse
*
* @throws AuthorizationException
* @throws AuthorizationException
*/
public function store(DiskEnvironmentRequest $request): JsonResponse|FileDiskResource
{
$this->authorize('manage file disk');
if (! $this->fileDiskService->validateCredentials($request->credentials, $request->driver)) {
return respondJson('invalid_credentials', 'Invalid Credentials.');
}
$disk = $this->fileDiskService->create($request);
return new FileDiskResource($disk);
}
/**
* @throws AuthorizationException
*/
public function update(FileDisk $disk, Request $request): JsonResponse|FileDiskResource
{
$this->authorize('manage file disk');
$credentials = $request->credentials;
$driver = $request->driver;
if ($credentials && $driver && $disk->type !== 'SYSTEM') {
if (! $this->fileDiskService->validateCredentials($credentials, $driver)) {
return respondJson('invalid_credentials', 'Invalid Credentials.');
}
$this->fileDiskService->update($disk, $request);
} elseif ($request->set_as_default) {
$this->fileDiskService->setAsDefault($disk);
}
return new FileDiskResource($disk);
}
/**
* @param Request $request
*
* @throws AuthorizationException
* @throws AuthorizationException
*/
public function show($disk): JsonResponse
{
$this->authorize('manage file disk');
$diskData = [];
switch ($disk) {
case 'local':
// Path is relative to storage/app/.
// e.g., "backups" resolves to storage/app/backups/ at runtime.
$diskData = [
'root' => '',
];
break;
case 's3':
$diskData = [
'key' => '',
'secret' => '',
'region' => '',
'bucket' => '',
'root' => '',
];
break;
case 's3compat':
$diskData = [
'endpoint' => '',
'key' => '',
'secret' => '',
'region' => '',
'bucket' => '',
'root' => '',
];
case 'doSpaces':
$diskData = [
'key' => '',
'secret' => '',
'region' => '',
'bucket' => '',
'endpoint' => '',
'root' => '',
];
break;
case 'dropbox':
$diskData = [
'token' => '',
'key' => '',
'secret' => '',
'app' => '',
'root' => '',
];
break;
}
$data = array_merge($diskData);
return response()->json($data);
}
/**
* Remove the specified resource from storage.
*
* @param FileDisk $taxType
*
* @throws AuthorizationException
* @throws AuthorizationException
*/
public function destroy(FileDisk $disk): JsonResponse
{
$this->authorize('manage file disk');
if ($disk->type === 'SYSTEM') {
return respondJson('not_allowed', 'System disks cannot be deleted.');
}
if ($disk->setAsDefault()) {
return respondJson('not_allowed', 'The default disk cannot be deleted.');
}
$prefix = env('DYNAMIC_DISK_PREFIX', 'temp_');
$diskName = $prefix.$disk->driver;
$mediaCount = DB::table('media')
->where('disk', $diskName)
->orWhere('disk', $disk->driver)
->count();
if ($mediaCount > 0) {
return respondJson('disk_has_files', 'Cannot delete this disk — it contains '.$mediaCount.' file(s). Migrate files first.');
}
$disk->delete();
return response()->json([
'success' => true,
]);
}
/**
* @throws AuthorizationException
* @throws AuthorizationException
*/
public function getDiskDrivers(): JsonResponse
{
$this->authorize('manage file disk');
$drivers = [
[
'name' => 'Local',
'value' => 'local',
],
[
'name' => 'Amazon S3',
'value' => 's3',
],
[
'name' => 'S3 Compatible Storage',
'value' => 's3compat',
],
[
'name' => 'Digital Ocean Spaces',
'value' => 'doSpaces',
],
[
'name' => 'Dropbox',
'value' => 'dropbox',
],
];
$defaultDisk = FileDisk::where('set_as_default', true)->first();
return response()->json([
'drivers' => $drivers,
'default' => $defaultDisk?->driver ?? 'local',
]);
}
public function getDiskPurposes(): JsonResponse
{
$this->authorize('manage file disk');
$defaultDisk = FileDisk::where('set_as_default', true)->first();
$defaultId = $defaultDisk?->id;
return response()->json([
'media_disk_id' => Setting::getSetting('media_disk_id') ?? $defaultId,
'pdf_disk_id' => Setting::getSetting('pdf_disk_id') ?? $defaultId,
'backup_disk_id' => Setting::getSetting('backup_disk_id') ?? $defaultId,
]);
}
public function updateDiskPurposes(Request $request): JsonResponse
{
$this->authorize('manage file disk');
$request->validate([
'media_disk_id' => 'nullable|exists:file_disks,id',
'pdf_disk_id' => 'nullable|exists:file_disks,id',
'backup_disk_id' => 'nullable|exists:file_disks,id',
]);
if ($request->has('media_disk_id')) {
Setting::setSetting('media_disk_id', $request->media_disk_id);
}
if ($request->has('pdf_disk_id')) {
Setting::setSetting('pdf_disk_id', $request->pdf_disk_id);
}
if ($request->has('backup_disk_id')) {
Setting::setSetting('backup_disk_id', $request->backup_disk_id);
}
return response()->json(['success' => true]);
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\Admin\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\MailEnvironmentRequest;
use App\Mail\TestMail;
use App\Models\Setting;
use App\Services\MailConfigurationService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\ValidationException;
class MailConfigurationController extends Controller
{
public function __construct(private readonly MailConfigurationService $mailConfigurationService) {}
/**
* Save the mail environment variables
*
*
*
* @throws AuthorizationException
*/
public function saveMailEnvironment(MailEnvironmentRequest $request): JsonResponse
{
$this->authorize('manage email config');
$setting = Setting::getSetting('profile_complete');
$this->mailConfigurationService->saveGlobalConfig($request->validated());
if ($setting !== 'COMPLETED') {
Setting::setSetting('profile_complete', 4);
}
return response()->json([
'success' => 'mail_variables_save_successfully',
]);
}
/**
* Return the mail environment variables
*
*
* @throws AuthorizationException
*/
public function getMailEnvironment(): JsonResponse
{
$this->authorize('manage email config');
return response()->json($this->mailConfigurationService->getGlobalConfig());
}
/**
* Return the available mail drivers
*
*
* @throws AuthorizationException
*/
public function getMailDrivers(): JsonResponse
{
$this->authorize('manage email config');
return response()->json($this->mailConfigurationService->getAvailableDrivers());
}
/**
* Test the email configuration
*
*
*
* @throws AuthorizationException
* @throws ValidationException
*/
public function testEmailConfig(Request $request): JsonResponse
{
$this->authorize('manage email config');
$this->validate($request, [
'to' => 'required|email',
'subject' => 'required',
'message' => 'required',
]);
Mail::to($request->to)->send(new TestMail($request->subject, $request->message));
return response()->json([
'success' => true,
]);
}
}

View File

@@ -1,25 +1,32 @@
<?php
namespace App\Http\Controllers\V1\Admin\Settings;
namespace App\Http\Controllers\Admin\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\PDFConfigurationRequest;
use App\Models\Setting;
use App\Space\EnvironmentManager;
use App\Services\Setup\EnvironmentManager;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
class PDFConfigurationController extends Controller
{
/**
* @var EnvironmentManager
*/
protected $environmentManager;
protected EnvironmentManager $environmentManager;
/**
* Constructor
*/
public function __construct(EnvironmentManager $environmentManager)
{
$this->environmentManager = $environmentManager;
}
public function getDrivers()
/**
* Returns the available drivers
*
* @throws AuthorizationException
*/
public function getDrivers(): JsonResponse
{
$this->authorize('manage pdf config');
@@ -31,7 +38,12 @@ class PDFConfigurationController extends Controller
return response()->json($drivers);
}
public function getEnvironment()
/**
* Return the PDF settings
*
* @throws AuthorizationException
*/
public function getEnvironment(): JsonResponse
{
$this->authorize('manage pdf config');
@@ -53,7 +65,12 @@ class PDFConfigurationController extends Controller
return response()->json($config);
}
public function saveEnvironment(PDFConfigurationRequest $request)
/**
* Saves the settings
*
* @throws AuthorizationException
*/
public function saveEnvironment(PDFConfigurationRequest $request): JsonResponse
{
$this->authorize('manage pdf config');
@@ -70,10 +87,8 @@ class PDFConfigurationController extends Controller
/**
* Prepare PDF settings for database storage
*
* @return array
*/
private function preparePDFSettingsForDatabase(PDFConfigurationRequest $request)
private function preparePDFSettingsForDatabase(PDFConfigurationRequest $request): array
{
$driver = $request->get('pdf_driver');

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Admin\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\GetSettingRequest;
use App\Http\Requests\SettingRequest;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
class SettingsController extends Controller
{
public function show(GetSettingRequest $request): JsonResponse
{
$this->authorize('manage settings');
$setting = Setting::getSetting($request->key);
return response()->json([
$request->key => $setting,
]);
}
public function update(SettingRequest $request): JsonResponse
{
$this->authorize('manage settings');
Setting::setSettings($request->settings);
return response()->json([
'success' => true,
$request->settings,
]);
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\Update\Updater;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
class UpdateController extends Controller
{
public function checkVersion(Request $request): JsonResponse
{
$this->ensureSuperAdmin();
set_time_limit(600);
$channel = $request->get('channel', 'stable');
$version = preg_replace('~[\r\n]+~', '', File::get(base_path('version.md')));
return response()->json(Updater::checkForUpdate($version, $channel));
}
public function download(Request $request): JsonResponse
{
$this->ensureSuperAdmin();
$request->validate(['version' => 'required']);
return response()->json([
'success' => true,
'path' => Updater::download($request->version),
]);
}
public function unzip(Request $request): JsonResponse
{
$this->ensureSuperAdmin();
$request->validate(['path' => 'required']);
try {
return response()->json([
'success' => true,
'path' => Updater::unzip($request->path),
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
public function copy(Request $request): JsonResponse
{
$this->ensureSuperAdmin();
$request->validate(['path' => 'required']);
return response()->json([
'success' => true,
'path' => Updater::copyFiles($request->path),
]);
}
public function delete(Request $request): JsonResponse
{
return $this->clean($request);
}
public function clean(Request $request): JsonResponse
{
$this->ensureSuperAdmin();
// Backward compatibility: use deleted_files when no manifest exists
if (! File::exists(base_path('manifest.json'))
&& isset($request->deleted_files)
&& ! empty($request->deleted_files)) {
Updater::deleteFiles($request->deleted_files);
return response()->json(['success' => true, 'cleaned' => 0]);
}
$result = Updater::cleanStaleFiles();
return response()->json($result);
}
public function migrate(Request $request): JsonResponse
{
$this->ensureSuperAdmin();
Updater::migrateUpdate();
return response()->json(['success' => true]);
}
public function finish(Request $request): JsonResponse
{
$this->ensureSuperAdmin();
$request->validate([
'installed' => 'required',
'version' => 'required',
]);
return response()->json(Updater::finishUpdate($request->installed, $request->version));
}
private function ensureSuperAdmin(): void
{
$this->authorize('manage update app');
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\AdminUserUpdateRequest;
use App\Http\Resources\UserResource;
use App\Models\ImpersonationLog;
use App\Models\User;
use Illuminate\Http\Request;
use Laravel\Sanctum\PersonalAccessToken;
class UsersController extends Controller
{
public function index(Request $request)
{
$limit = $request->has('limit') ? $request->limit : 10;
$users = User::with('companies')
->applyFilters($request->all())
->latest()
->paginate($limit);
return UserResource::collection($users);
}
public function show(User $user)
{
$user->load('companies');
return new UserResource($user);
}
public function update(AdminUserUpdateRequest $request, User $user)
{
$data = $request->only(['name', 'email', 'phone']);
if ($request->filled('password')) {
$data['password'] = $request->password;
}
$user->update($data);
return new UserResource($user);
}
public function impersonate(Request $request, User $user)
{
$admin = $request->user();
if ($admin->id === $user->id) {
return response()->json([
'error' => 'cannot_impersonate_self',
'message' => 'You cannot impersonate yourself.',
], 422);
}
$token = $user->createToken(
'impersonation-by-'.$admin->id,
['*'],
now()->addHours(2),
);
$log = ImpersonationLog::create([
'admin_id' => $admin->id,
'user_id' => $user->id,
'ip_address' => $request->ip(),
'token_id' => $token->accessToken->id,
]);
return response()->json([
'token' => $token->plainTextToken,
'impersonation_log_id' => $log->id,
'user' => new UserResource($user),
]);
}
public function stopImpersonating(Request $request)
{
$token = $request->user()->currentAccessToken();
if ($token instanceof PersonalAccessToken && str_starts_with($token->name, 'impersonation-by-')) {
$log = ImpersonationLog::where('token_id', $token->id)
->whereNull('stopped_at')
->first();
if ($log) {
$log->update(['stopped_at' => now()]);
}
$token->delete();
return response()->json(['success' => true]);
}
return response()->json([
'error' => 'not_impersonating',
'message' => 'No active impersonation session.',
], 422);
}
}

View File

@@ -1,10 +1,12 @@
<?php
namespace App\Http\Controllers\V1\Admin\Mobile;
namespace App\Http\Controllers\Company\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\LoginRequest;
use App\Models\CompanyInvitation;
use App\Models\User;
use App\Services\InvitationService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
@@ -14,7 +16,7 @@ class AuthController extends Controller
{
public function login(LoginRequest $request)
{
$user = User::where('email', $request->username)->first();
$user = User::whereRaw('LOWER(email) = ?', [strtolower($request->username)])->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
@@ -22,6 +24,17 @@ class AuthController extends Controller
]);
}
// Auto-accept invitation if token is provided
if ($request->has('invitation_token') && $request->invitation_token) {
$invitation = CompanyInvitation::where('token', $request->invitation_token)
->pending()
->first();
if ($invitation) {
app(InvitationService::class)->accept($invitation, $user);
}
}
return response()->json([
'type' => 'Bearer',
'token' => $user->createToken($request->device_name)->plainTextToken,

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\Auth;
namespace App\Http\Controllers\Company\Auth;
use App\Http\Controllers\Controller;
use App\Providers\AppServiceProvider;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\Auth;
namespace App\Http\Controllers\Company\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Company\Auth;
use App\Http\Controllers\Controller;
use App\Models\CompanyInvitation;
use App\Models\User;
use App\Services\InvitationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class InvitationRegistrationController extends Controller
{
public function __construct(
private readonly InvitationService $invitationService,
) {}
/**
* Get invitation details by token (public endpoint for registration page).
*/
public function details(string $token): JsonResponse
{
$invitation = CompanyInvitation::where('token', $token)
->pending()
->with(['company', 'role'])
->first();
if (! $invitation) {
return response()->json([
'error' => 'Invitation not found or expired.',
], 404);
}
return response()->json([
'email' => $invitation->email,
'company_name' => $invitation->company->name,
'role_name' => $invitation->role->title,
]);
}
/**
* Register a new user and auto-accept the invitation.
*/
public function register(Request $request): JsonResponse
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8|confirmed',
'invitation_token' => 'required|string',
]);
$invitation = CompanyInvitation::where('token', $request->invitation_token)
->pending()
->first();
if (! $invitation) {
throw ValidationException::withMessages([
'invitation_token' => ['Invitation not found or expired.'],
]);
}
if ($invitation->email !== $request->email) {
throw ValidationException::withMessages([
'email' => ['Email does not match the invitation.'],
]);
}
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => $request->password,
]);
$user->setSettings(['language' => 'default']);
$this->invitationService->accept($invitation, $user);
return response()->json([
'type' => 'Bearer',
'token' => $user->createToken('web')->plainTextToken,
]);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\Auth;
namespace App\Http\Controllers\Company\Auth;
use App\Http\Controllers\Controller;
use App\Providers\AppServiceProvider;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\Auth;
namespace App\Http\Controllers\Company\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
@@ -61,10 +61,14 @@ class RegisterController extends Controller
*/
protected function create(array $data)
{
return User::create([
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => $data['password'],
]);
$user->setSettings(['language' => 'default']);
return $user;
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\Auth;
namespace App\Http\Controllers\Company\Auth;
use App\Http\Controllers\Controller;
use App\Providers\AppServiceProvider;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\Auth;
namespace App\Http\Controllers\Company\Auth;
use App\Http\Controllers\Controller;
use App\Providers\AppServiceProvider;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\Config;
namespace App\Http\Controllers\Company\Config;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\Config;
namespace App\Http\Controllers\Company\Config;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\Config;
namespace App\Http\Controllers\Company\Config;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

View File

@@ -1,16 +1,21 @@
<?php
namespace App\Http\Controllers\V1\Admin\CustomField;
namespace App\Http\Controllers\Company\CustomField;
use App\Http\Controllers\Controller;
use App\Http\Requests\CustomFieldRequest;
use App\Http\Resources\CustomFieldResource;
use App\Models\CustomField;
use App\Services\CustomFieldService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class CustomFieldsController extends Controller
{
public function __construct(
private readonly CustomFieldService $customFieldService,
) {}
/**
* Display a listing of the resource.
*
@@ -40,7 +45,7 @@ class CustomFieldsController extends Controller
{
$this->authorize('create', CustomField::class);
$customField = CustomField::createCustomField($request);
$customField = $this->customFieldService->create($request);
return new CustomFieldResource($customField);
}
@@ -69,7 +74,7 @@ class CustomFieldsController extends Controller
{
$this->authorize('update', $customField);
$customField->updateCustomField($request);
$this->customFieldService->update($customField, $request);
return new CustomFieldResource($customField);
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Company\Customer;
use App\Http\Controllers\Controller;
use App\Http\Resources\CustomerResource;
use App\Models\Customer;
use App\Services\CustomerService;
use Illuminate\Http\Request;
class CustomerStatsController extends Controller
{
public function __construct(
private readonly CustomerService $customerService,
) {}
public function __invoke(Request $request, Customer $customer)
{
$this->authorize('view', $customer);
$chartData = $this->customerService->getStats(
$customer,
$request->header('company'),
$request->has('previous_year')
);
$customer = Customer::find($customer->id);
return (new CustomerResource($customer))
->additional(['meta' => [
'chartData' => $chartData,
]]);
}
}

View File

@@ -1,17 +1,22 @@
<?php
namespace App\Http\Controllers\V1\Admin\Customer;
namespace App\Http\Controllers\Company\Customer;
use App\Http\Controllers\Controller;
use App\Http\Requests;
use App\Http\Requests\DeleteCustomersRequest;
use App\Http\Resources\CustomerResource;
use App\Models\Customer;
use App\Services\CustomerService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CustomersController extends Controller
{
public function __construct(
private readonly CustomerService $customerService,
) {}
/**
* Display a listing of the resource.
*
@@ -46,7 +51,7 @@ class CustomersController extends Controller
{
$this->authorize('create', Customer::class);
$customer = Customer::createCustomer($request);
$customer = $this->customerService->create($request);
return new CustomerResource($customer);
}
@@ -73,11 +78,7 @@ class CustomersController extends Controller
{
$this->authorize('update', $customer);
$customer = Customer::updateCustomer($request, $customer);
if (is_string($customer)) {
return respondJson('you_cannot_edit_currency', 'Cannot change currency once transactions created');
}
$customer = $this->customerService->update($request, $customer);
return new CustomerResource($customer);
}
@@ -96,7 +97,7 @@ class CustomersController extends Controller
->whereIn('id', $request->ids)
->pluck('id');
Customer::deleteCustomers($ids);
$this->customerService->delete($ids);
return response()->json([
'success' => true,

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\Dashboard;
namespace App\Http\Controllers\Company\Dashboard;
use App\Http\Controllers\Controller;
use App\Models\Company;
@@ -60,39 +60,27 @@ class DashboardController extends Controller
}
while ($monthCounter < 12) {
array_push(
$invoice_totals,
Invoice::whereBetween(
'invoice_date',
[$start->format('Y-m-d'), $end->format('Y-m-d')]
)
->whereCompany()
->sum('base_total')
);
array_push(
$expense_totals,
Expense::whereBetween(
'expense_date',
[$start->format('Y-m-d'), $end->format('Y-m-d')]
)
->whereCompany()
->sum('base_amount')
);
array_push(
$receipt_totals,
Payment::whereBetween(
'payment_date',
[$start->format('Y-m-d'), $end->format('Y-m-d')]
)
->whereCompany()
->sum('base_amount')
);
array_push(
$net_income_totals,
($receipt_totals[$i] - $expense_totals[$i])
);
$invoice_totals[] = Invoice::whereBetween(
'invoice_date',
[$start->format('Y-m-d'), $end->format('Y-m-d')]
)
->whereCompany()
->sum('base_total');
$expense_totals[] = Expense::whereBetween(
'expense_date',
[$start->format('Y-m-d'), $end->format('Y-m-d')]
)
->whereCompany()
->sum('base_amount');
$receipt_totals[] = Payment::whereBetween(
'payment_date',
[$start->format('Y-m-d'), $end->format('Y-m-d')]
)
->whereCompany()
->sum('base_amount');
$net_income_totals[] = ($receipt_totals[$i] - $expense_totals[$i]);
$i++;
array_push($months, $start->translatedFormat('M'));
$months[] = $start->translatedFormat('M');
$monthCounter++;
$end->startOfMonth();
$start->addMonth()->startOfMonth();

View File

@@ -1,10 +1,10 @@
<?php
namespace App\Http\Controllers\V1\Admin\Estimate;
namespace App\Http\Controllers\Company\Estimate;
use App\Http\Controllers\Controller;
use App\Models\Estimate;
use App\Space\PdfTemplateUtils;
use App\Services\Pdf\PdfTemplateUtils;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Http\Controllers\Company\Estimate;
use App\Http\Controllers\Controller;
use App\Http\Requests\DeleteEstimatesRequest;
use App\Http\Requests\EstimatesRequest;
use App\Http\Requests\SendEstimatesRequest;
use App\Http\Resources\EstimateResource;
use App\Http\Resources\InvoiceResource;
use App\Jobs\GenerateEstimatePdfJob;
use App\Models\Estimate;
use App\Models\Invoice;
use App\Services\EstimateService;
use Illuminate\Http\Request;
use Illuminate\Mail\Markdown;
class EstimatesController extends Controller
{
public function __construct(
private readonly EstimateService $estimateService,
) {}
public function index(Request $request)
{
$this->authorize('viewAny', Estimate::class);
$limit = $request->has('limit') ? $request->limit : 10;
$estimates = Estimate::whereCompany()
->join('customers', 'customers.id', '=', 'estimates.customer_id')
->applyFilters($request->all())
->select('estimates.*', 'customers.name')
->latest()
->paginateData($limit);
return EstimateResource::collection($estimates)
->additional(['meta' => [
'estimate_total_count' => Estimate::whereCompany()->count(),
]]);
}
public function store(EstimatesRequest $request)
{
$this->authorize('create', Estimate::class);
$estimate = $this->estimateService->create($request);
if ($request->has('estimateSend')) {
$this->estimateService->send($estimate, $request->only(['title', 'body']));
}
GenerateEstimatePdfJob::dispatch($estimate);
return new EstimateResource($estimate);
}
public function show(Request $request, Estimate $estimate)
{
$this->authorize('view', $estimate);
return new EstimateResource($estimate);
}
public function update(EstimatesRequest $request, Estimate $estimate)
{
$this->authorize('update', $estimate);
$estimate = $this->estimateService->update($estimate, $request);
GenerateEstimatePdfJob::dispatch($estimate, true);
return new EstimateResource($estimate);
}
public function delete(DeleteEstimatesRequest $request)
{
$this->authorize('delete multiple estimates');
$ids = Estimate::whereCompany()
->whereIn('id', $request->ids)
->pluck('id');
Estimate::destroy($ids);
return response()->json([
'success' => true,
]);
}
public function send(SendEstimatesRequest $request, Estimate $estimate)
{
$this->authorize('send estimate', $estimate);
$response = $this->estimateService->send($estimate, $request->all());
return response()->json($response);
}
public function sendPreview(SendEstimatesRequest $request, Estimate $estimate)
{
$this->authorize('send estimate', $estimate);
$markdown = new Markdown(view(), config('mail.markdown'));
$data = $this->estimateService->sendEstimateData($estimate, $request->all());
$data['url'] = $estimate->estimatePdfUrl;
return $markdown->render('emails.send.estimate', ['data' => $data]);
}
public function clone(Request $request, Estimate $estimate)
{
$this->authorize('view', $estimate);
$this->authorize('create', Estimate::class);
$newEstimate = $this->estimateService->clone($estimate);
return new EstimateResource($newEstimate);
}
public function convertToInvoice(Request $request, Estimate $estimate)
{
$this->authorize('create', Invoice::class);
$invoice = $this->estimateService->convertToInvoice($estimate);
return new InvoiceResource($invoice);
}
public function changeStatus(Request $request, Estimate $estimate)
{
$this->authorize('send estimate', $estimate);
$this->estimateService->changeStatus($estimate, $request->status);
return response()->json([
'success' => true,
]);
}
}

View File

@@ -0,0 +1,359 @@
<?php
namespace App\Http\Controllers\Company\ExchangeRate;
use App\Http\Controllers\Controller;
use App\Http\Requests\BulkExchangeRateRequest;
use App\Http\Requests\ExchangeRateProviderRequest;
use App\Http\Resources\ExchangeRateProviderResource;
use App\Models\CompanySetting;
use App\Models\Currency;
use App\Models\Estimate;
use App\Models\ExchangeRateLog;
use App\Models\ExchangeRateProvider;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\Tax;
use App\Services\ExchangeRateProviderService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Arr;
class ExchangeRateProviderController extends Controller
{
public function __construct(
private readonly ExchangeRateProviderService $exchangeRateProviderService,
) {}
/**
* Display a listing of the resource.
*
* @return Response
*/
public function index(Request $request)
{
$this->authorize('viewAny', ExchangeRateProvider::class);
$limit = $request->has('limit') ? $request->limit : 5;
$exchangeRateProviders = ExchangeRateProvider::whereCompany()->paginate($limit);
return ExchangeRateProviderResource::collection($exchangeRateProviders);
}
/**
* Store a newly created resource in storage.
*
* @param Request $request
* @return Response
*/
public function store(ExchangeRateProviderRequest $request)
{
$this->authorize('create', ExchangeRateProvider::class);
$query = $this->exchangeRateProviderService->checkActiveCurrencies($request);
if (count($query) !== 0) {
return respondJson('currency_used', 'Currency used.');
}
$checkConverterApi = $this->exchangeRateProviderService->checkProviderStatus($request);
if ($checkConverterApi->status() == 200) {
$exchangeRateProvider = $this->exchangeRateProviderService->create($request);
return new ExchangeRateProviderResource($exchangeRateProvider);
}
return $checkConverterApi;
}
/**
* Display the specified resource.
*
* @return Response
*/
public function show(ExchangeRateProvider $exchangeRateProvider)
{
$this->authorize('view', $exchangeRateProvider);
return new ExchangeRateProviderResource($exchangeRateProvider);
}
/**
* Update the specified resource in storage.
*
* @param Request $request
* @return Response
*/
public function update(ExchangeRateProviderRequest $request, ExchangeRateProvider $exchangeRateProvider)
{
$this->authorize('update', $exchangeRateProvider);
$query = $this->exchangeRateProviderService->checkUpdateActiveCurrencies($exchangeRateProvider, $request);
if (count($query) !== 0) {
return respondJson('currency_used', 'Currency used.');
}
$checkConverterApi = $this->exchangeRateProviderService->checkProviderStatus($request);
if ($checkConverterApi->status() == 200) {
$this->exchangeRateProviderService->update($exchangeRateProvider, $request);
return new ExchangeRateProviderResource($exchangeRateProvider);
}
return $checkConverterApi;
}
/**
* Remove the specified resource from storage.
*
* @return Response
*/
public function destroy(ExchangeRateProvider $exchangeRateProvider)
{
$this->authorize('delete', $exchangeRateProvider);
if ($exchangeRateProvider->active == true) {
return respondJson('provider_active', 'Provider Active.');
}
$exchangeRateProvider->delete();
return response()->json([
'success' => true,
]);
}
public function activeProvider(Request $request, Currency $currency)
{
$query = ExchangeRateProvider::whereCompany()->whereJsonContains('currencies', $currency->code)
->where('active', true)
->get();
if (count($query) !== 0) {
return response()->json([
'success' => true,
'message' => 'provider_active',
], 200);
}
return response()->json([
'error' => 'no_active_provider',
], 200);
}
public function getRate(Request $request, Currency $currency)
{
$settings = CompanySetting::getSettings(['currency'], $request->header('company'));
$baseCurrency = Currency::findOrFail($settings['currency']);
$query = ExchangeRateProvider::whereJsonContains('currencies', $currency->code)
->where('active', true)
->get()
->toArray();
$exchangeRate = ExchangeRateLog::where('base_currency_id', $currency->id)
->where('currency_id', $baseCurrency->id)
->orderBy('created_at', 'desc')
->value('exchange_rate');
if ($query) {
$filter = Arr::only($query[0], ['key', 'driver', 'driver_config']);
$result = $this->exchangeRateProviderService->getExchangeRate(
$filter['driver'],
$filter['key'],
$filter['driver_config'] ?? [],
$currency->code,
$baseCurrency->code
);
if ($result->status() == 200) {
return $result;
}
}
if ($exchangeRate) {
return response()->json([
'exchangeRate' => [$exchangeRate],
], 200);
}
return response()->json([
'error' => 'no_exchange_rate_available',
], 200);
}
public function supportedCurrencies(Request $request)
{
$this->authorize('viewAny', ExchangeRateProvider::class);
return $this->exchangeRateProviderService->getSupportedCurrencies(
$request->driver,
$request->key,
$request->driver_config ?? []
);
}
public function usedCurrencies(Request $request)
{
$this->authorize('viewAny', ExchangeRateProvider::class);
$providerId = $request->provider_id;
$activeExchangeRateProviders = ExchangeRateProvider::where('active', true)
->whereCompany()
->when($providerId, function ($query) use ($providerId) {
return $query->where('id', '<>', $providerId);
})
->pluck('currencies');
$activeExchangeRateProvider = [];
foreach ($activeExchangeRateProviders as $data) {
if (is_array($data)) {
for ($limit = 0; $limit < count($data); $limit++) {
$activeExchangeRateProvider[] = $data[$limit];
}
}
}
$allExchangeRateProviders = ExchangeRateProvider::whereCompany()->pluck('currencies');
$allExchangeRateProvider = [];
foreach ($allExchangeRateProviders as $data) {
if (is_array($data)) {
for ($limit = 0; $limit < count($data); $limit++) {
$allExchangeRateProvider[] = $data[$limit];
}
}
}
return response()->json([
'allUsedCurrencies' => $allExchangeRateProvider ? $allExchangeRateProvider : [],
'activeUsedCurrencies' => $activeExchangeRateProvider ? $activeExchangeRateProvider : [],
]);
}
public function usedCurrenciesWithoutRate(Request $request)
{
$invoices = Invoice::where('exchange_rate', null)->pluck('currency_id')->toArray();
$taxes = Tax::where('exchange_rate', null)->pluck('currency_id')->toArray();
$estimates = Estimate::where('exchange_rate', null)->pluck('currency_id')->toArray();
$payments = Payment::where('exchange_rate', null)->pluck('currency_id')->toArray();
$currencies = array_merge($invoices, $taxes, $estimates, $payments);
return response()->json([
'currencies' => Currency::whereIn('id', $currencies)->get(),
]);
}
public function bulkUpdate(BulkExchangeRateRequest $request)
{
$bulkExchangeRate = CompanySetting::getSetting('bulk_exchange_rate_configured', $request->header('company'));
if ($bulkExchangeRate == 'NO') {
if ($request->currencies) {
foreach ($request->currencies as $currency) {
$currency['exchange_rate'] = $currency['exchange_rate'] ?? 1;
$invoices = Invoice::where('currency_id', $currency['id'])->get();
if ($invoices) {
foreach ($invoices as $invoice) {
$invoice->update([
'exchange_rate' => $currency['exchange_rate'],
'base_discount_val' => $invoice->sub_total * $currency['exchange_rate'],
'base_sub_total' => $invoice->sub_total * $currency['exchange_rate'],
'base_total' => $invoice->total * $currency['exchange_rate'],
'base_tax' => $invoice->tax * $currency['exchange_rate'],
'base_due_amount' => $invoice->due_amount * $currency['exchange_rate'],
]);
$this->updateItemsExchangeRate($invoice);
}
}
$estimates = Estimate::where('currency_id', $currency['id'])->get();
if ($estimates) {
foreach ($estimates as $estimate) {
$estimate->update([
'exchange_rate' => $currency['exchange_rate'],
'base_discount_val' => $estimate->sub_total * $currency['exchange_rate'],
'base_sub_total' => $estimate->sub_total * $currency['exchange_rate'],
'base_total' => $estimate->total * $currency['exchange_rate'],
'base_tax' => $estimate->tax * $currency['exchange_rate'],
]);
$this->updateItemsExchangeRate($estimate);
}
}
$taxes = Tax::where('currency_id', $currency['id'])->get();
if ($taxes) {
foreach ($taxes as $tax) {
$tax->base_amount = $tax->base_amount * $currency['exchange_rate'];
$tax->save();
}
}
$payments = Payment::where('currency_id', $currency['id'])->get();
if ($payments) {
foreach ($payments as $payment) {
$payment->exchange_rate = $currency['exchange_rate'];
$payment->base_amount = $payment->amount * $currency['exchange_rate'];
$payment->save();
}
}
}
}
$settings = [
'bulk_exchange_rate_configured' => 'YES',
];
CompanySetting::setSettings($settings, $request->header('company'));
return response()->json([
'success' => true,
]);
}
return response()->json([
'error' => false,
]);
}
private function updateItemsExchangeRate($model): void
{
foreach ($model->items as $item) {
$item->update([
'exchange_rate' => $model->exchange_rate,
'base_discount_val' => $item->discount_val * $model->exchange_rate,
'base_price' => $item->price * $model->exchange_rate,
'base_tax' => $item->tax * $model->exchange_rate,
'base_total' => $item->total * $model->exchange_rate,
]);
$this->updateTaxesExchangeRate($item);
}
$this->updateTaxesExchangeRate($model);
}
private function updateTaxesExchangeRate($model): void
{
if ($model->taxes()->exists()) {
$model->taxes->map(function ($tax) use ($model) {
$tax->update([
'exchange_rate' => $model->exchange_rate,
'base_amount' => $tax->amount * $model->exchange_rate,
]);
});
}
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\Expense;
namespace App\Http\Controllers\Company\Expense;
use App\ExpensesCategory;
use App\Http\Controllers\Controller;

View File

@@ -1,17 +1,23 @@
<?php
namespace App\Http\Controllers\V1\Admin\Expense;
namespace App\Http\Controllers\Company\Expense;
use App\Http\Controllers\Controller;
use App\Http\Requests\DeleteExpensesRequest;
use App\Http\Requests\ExpenseRequest;
use App\Http\Requests\UploadExpenseReceiptRequest;
use App\Http\Resources\ExpenseResource;
use App\Models\Expense;
use App\Services\ExpenseService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ExpensesController extends Controller
{
public function __construct(
private readonly ExpenseService $expenseService,
) {}
/**
* Display a listing of the resource.
*
@@ -46,7 +52,7 @@ class ExpensesController extends Controller
{
$this->authorize('create', Expense::class);
$expense = Expense::createExpense($request);
$expense = $this->expenseService->create($request);
return new ExpenseResource($expense);
}
@@ -72,7 +78,7 @@ class ExpensesController extends Controller
{
$this->authorize('update', $expense);
$expense->updateExpense($request);
$this->expenseService->update($expense, $request);
return new ExpenseResource($expense);
}
@@ -91,4 +97,62 @@ class ExpensesController extends Controller
'success' => true,
]);
}
public function showReceipt(Expense $expense)
{
$this->authorize('view', $expense);
if ($expense) {
$media = $expense->getFirstMedia('receipts');
if ($media) {
return response()->file($media->getPath());
}
return respondJson('receipt_does_not_exist', 'Receipt does not exist.');
}
}
public function uploadReceipt(UploadExpenseReceiptRequest $request, Expense $expense)
{
$this->authorize('update', $expense);
$data = json_decode($request->attachment_receipt);
if ($data) {
if ($request->type === 'edit') {
$expense->clearMediaCollection('receipts');
}
$expense->addMediaFromBase64($data->data)
->usingFileName($data->name)
->toMediaCollection('receipts');
}
return response()->json([
'success' => 'Expense receipts uploaded successfully',
], 200);
}
public function downloadReceipt(Expense $expense)
{
$this->authorize('view', $expense);
if ($expense) {
$media = $expense->getFirstMedia('receipts');
if ($media) {
$imagePath = $media->getPath();
$response = \Response::download($imagePath, $media->file_name);
if (ob_get_contents()) {
ob_end_clean();
}
return $response;
}
}
return response()->json([
'error' => 'receipt_not_found',
]);
}
}

View File

@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers\Company\General;
use App\Http\Controllers\Controller;
use App\Http\Resources\CompanyInvitationResource;
use App\Http\Resources\CompanyResource;
use App\Http\Resources\UserResource;
use App\Models\Company;
use App\Models\CompanyInvitation;
use App\Models\CompanySetting;
use App\Models\Currency;
use App\Models\Module;
use App\Models\Setting;
use App\Traits\GeneratesMenuTrait;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use InvoiceShelf\Modules\Registry as ModuleRegistry;
use Silber\Bouncer\BouncerFacade;
class BootstrapController extends Controller
{
use GeneratesMenuTrait;
/**
* Handle the incoming request.
*
* @return JsonResponse
*/
public function __invoke(Request $request)
{
$current_user = $request->user();
$current_user_settings = $current_user->getAllSettings();
$companies = $current_user->companies;
$pendingInvitations = CompanyInvitation::forUser($current_user)
->pending()
->with(['company', 'role', 'invitedBy'])
->get();
$global_settings = Setting::getSettings([
'api_token',
'admin_portal_theme',
'admin_portal_logo',
'login_page_logo',
'login_page_heading',
'login_page_description',
'admin_page_title',
'copyright_text',
'save_pdf_to_disk',
'show_sidebar_group_labels',
]);
// Super admin mode — return admin-only menu with all companies listed
if ($current_user->isSuperAdmin() && $request->has('admin_mode')) {
return response()->json([
'current_user' => new UserResource($current_user),
'current_user_settings' => $current_user_settings,
'current_user_abilities' => [],
'companies' => CompanyResource::collection($companies),
'current_company' => null,
'current_company_settings' => [],
'current_company_currency' => Currency::first(),
'config' => config('invoiceshelf'),
'global_settings' => $global_settings,
'main_menu' => $this->generateMenu('admin_menu', $current_user),
'setting_menu' => [],
'modules' => [],
'admin_mode' => true,
'pending_invitations' => CompanyInvitationResource::collection($pendingInvitations),
]);
}
// User has no companies — return minimal bootstrap
if ($companies->isEmpty()) {
return response()->json([
'current_user' => new UserResource($current_user),
'current_user_settings' => $current_user_settings,
'current_user_abilities' => [],
'companies' => [],
'current_company' => null,
'current_company_settings' => [],
'current_company_currency' => Currency::first(),
'config' => config('invoiceshelf'),
'global_settings' => $global_settings,
'main_menu' => [],
'setting_menu' => [],
'modules' => [],
'pending_invitations' => CompanyInvitationResource::collection($pendingInvitations),
]);
}
$main_menu = $this->generateMenu('main_menu', $current_user);
$setting_menu = $this->generateMenu('setting_menu', $current_user);
// Merge module-registered menu items into the main menu so they
// participate in the unified group + priority ordering.
foreach (ModuleRegistry::allMenu() as $slug => $item) {
$main_menu[] = [
'title' => __($item['title']),
'link' => $item['link'],
'icon' => $item['icon'],
'name' => 'module-'.$slug,
'group' => $item['group'] ?? 'modules',
'group_label' => $item['group_label'] ?? 'navigation.modules',
'priority' => $item['priority'] ?? 100,
];
}
$current_company = Company::find($request->header('company'));
if ((! $current_company) || ($current_company && ! $current_user->hasCompany($current_company->id))) {
$current_company = $current_user->companies()->first();
}
$current_company_settings = CompanySetting::getAllSettings($current_company->id);
$current_company_currency = $current_company_settings->has('currency')
? Currency::find($current_company_settings->get('currency'))
: Currency::first();
BouncerFacade::refreshFor($current_user);
return response()->json([
'current_user' => new UserResource($current_user),
'current_user_settings' => $current_user_settings,
'current_user_abilities' => $current_user->getAbilities(),
'companies' => CompanyResource::collection($companies),
'current_company' => new CompanyResource($current_company),
'current_company_settings' => $current_company_settings,
'current_company_currency' => $current_company_currency,
'config' => config('invoiceshelf'),
'global_settings' => $global_settings,
'main_menu' => $main_menu,
'setting_menu' => $setting_menu,
'modules' => Module::where('enabled', true)->pluck('name'),
'user_menu' => collect(ModuleRegistry::allUserMenu())
->map(fn (array $item, string $slug) => [
...$item,
'title' => __($item['title']),
'name' => 'module-'.$slug,
])
->sortBy('priority')
->values()
->all(),
'pending_invitations' => CompanyInvitationResource::collection($pendingInvitations),
]);
}
public function currentCompany(Request $request)
{
$company = Company::find($request->header('company'));
return new CompanyResource($company);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Company\General;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use InvoiceShelf\Modules\Registry;
class ConfigController extends Controller
{
/**
* Handle the incoming request.
*/
public function __invoke(Request $request): JsonResponse
{
if ($request->key === 'exchange_rate_drivers') {
return response()->json([
'exchange_rate_drivers' => $this->exchangeRateDrivers(),
]);
}
return response()->json([
$request->key => config('invoiceshelf.'.$request->key),
]);
}
/**
* Build the exchange rate driver list from the module Registry.
*
* Returns enriched objects (with label, website, and config_fields) so the
* frontend can render driver-specific configuration forms without hardcoding
* any per-driver UI.
*
* @return array<int, array<string, mixed>>
*/
protected function exchangeRateDrivers(): array
{
return collect(Registry::allDrivers('exchange_rate'))
->map(fn (array $meta, string $name) => [
'value' => $name,
'label' => $meta['label'] ?? $name,
'website' => $meta['website'] ?? '',
'config_fields' => $meta['config_fields'] ?? [],
])
->values()
->all();
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Company\General;
use App\Http\Controllers\Controller;
use App\Support\Formatters\DateFormatter;
use App\Support\Formatters\TimeFormatter;
use App\Support\Formatters\TimeZones;
use Illuminate\Http\JsonResponse;
class FormatsController extends Controller
{
public function dateFormats(): JsonResponse
{
return response()->json([
'date_formats' => DateFormatter::get_list(),
]);
}
public function timeFormats(): JsonResponse
{
return response()->json([
'time_formats' => TimeFormatter::get_list(),
]);
}
public function timezones(): JsonResponse
{
return response()->json([
'time_zones' => TimeZones::get_list(),
]);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Company\General;
use App\Http\Controllers\Controller;
use App\Http\Resources\CompanyInvitationResource;
use App\Models\CompanyInvitation;
use App\Services\InvitationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class InvitationResponseController extends Controller
{
public function __construct(
private readonly InvitationService $invitationService,
) {}
/**
* Get pending invitations for the authenticated user.
*/
public function pending(Request $request): JsonResponse
{
$invitations = $this->invitationService->getPendingForUser($request->user());
return response()->json([
'invitations' => CompanyInvitationResource::collection($invitations),
]);
}
/**
* Accept a company invitation.
*/
public function accept(Request $request, CompanyInvitation $invitation): JsonResponse
{
$this->invitationService->accept($invitation, $request->user());
return response()->json(['success' => true]);
}
/**
* Decline a company invitation.
*/
public function decline(Request $request, CompanyInvitation $invitation): JsonResponse
{
$this->invitationService->decline($invitation, $request->user());
return response()->json(['success' => true]);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\General;
namespace App\Http\Controllers\Company\General;
use App\Http\Controllers\Controller;
use App\Http\Requests\NotesRequest;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\General;
namespace App\Http\Controllers\Company\General;
use App\Http\Controllers\Controller;
use App\Models\Customer;
@@ -36,4 +36,15 @@ class SearchController extends Controller
'users' => $users ?? [],
]);
}
public function users(Request $request)
{
$this->authorize('create', User::class);
$users = User::whereEmail($request->email)
->latest()
->paginate(10);
return response()->json(['users' => $users]);
}
}

View File

@@ -1,27 +1,22 @@
<?php
namespace App\Http\Controllers\V1\Admin\General;
namespace App\Http\Controllers\Company\General;
use App\Http\Controllers\Controller;
use App\Models\Estimate;
use App\Models\Invoice;
use App\Models\Payment;
use App\Services\SerialNumberFormatter;
use App\Services\SerialNumberService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class NextNumberController extends Controller
class SerialNumberController extends Controller
{
/**
* Handle the incoming request.
*
* @return Response
*/
public function __invoke(Request $request, Invoice $invoice, Estimate $estimate, Payment $payment)
public function nextNumber(Request $request, Invoice $invoice, Estimate $estimate, Payment $payment): JsonResponse
{
$key = $request->key;
$nextNumber = null;
$serial = (new SerialNumberFormatter)
$serial = (new SerialNumberService)
->setCompany($request->header('company'))
->setCustomer($request->userId);
@@ -49,7 +44,9 @@ class NextNumberController extends Controller
break;
default:
return;
return response()->json([
'success' => false,
]);
}
} catch (\Exception $exception) {
return response()->json([
@@ -63,4 +60,18 @@ class NextNumberController extends Controller
'nextNumber' => $nextNumber,
]);
}
public function placeholders(Request $request): JsonResponse
{
if ($request->input('format')) {
$placeholders = SerialNumberService::getPlaceholders($request->input('format'));
} else {
$placeholders = [];
}
return response()->json([
'success' => true,
'placeholders' => $placeholders,
]);
}
}

View File

@@ -1,10 +1,10 @@
<?php
namespace App\Http\Controllers\V1\Admin\Invoice;
namespace App\Http\Controllers\Company\Invoice;
use App\Http\Controllers\Controller;
use App\Models\Invoice;
use App\Space\PdfTemplateUtils;
use App\Services\Pdf\PdfTemplateUtils;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

View File

@@ -1,18 +1,27 @@
<?php
namespace App\Http\Controllers\V1\Admin\Invoice;
namespace App\Http\Controllers\Company\Invoice;
use App\Http\Controllers\Controller;
use App\Http\Requests;
use App\Http\Requests\DeleteInvoiceRequest;
use App\Http\Requests\SendInvoiceRequest;
use App\Http\Resources\EstimateResource;
use App\Http\Resources\InvoiceResource;
use App\Jobs\GenerateInvoicePdfJob;
use App\Models\Estimate;
use App\Models\Invoice;
use App\Services\InvoiceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Mail\Markdown;
class InvoicesController extends Controller
{
public function __construct(
private readonly InvoiceService $invoiceService,
) {}
/**
* Display a listing of the resource.
*
@@ -46,10 +55,10 @@ class InvoicesController extends Controller
{
$this->authorize('create', Invoice::class);
$invoice = Invoice::createInvoice($request);
$invoice = $this->invoiceService->create($request);
if ($request->has('invoiceSend')) {
$invoice->send($request->subject, $request->body);
$this->invoiceService->send($invoice, $request->only(['subject', 'body']));
}
GenerateInvoicePdfJob::dispatch($invoice);
@@ -79,11 +88,7 @@ class InvoicesController extends Controller
{
$this->authorize('update', $invoice);
$invoice = $invoice->updateInvoice($request);
if (is_string($invoice)) {
return respondJson($invoice, $invoice);
}
$invoice = $this->invoiceService->update($invoice, $request);
GenerateInvoicePdfJob::dispatch($invoice, true);
@@ -104,7 +109,60 @@ class InvoicesController extends Controller
->whereIn('id', $request->ids)
->pluck('id');
Invoice::deleteInvoices($ids);
$this->invoiceService->delete($ids);
return response()->json([
'success' => true,
]);
}
public function send(SendInvoiceRequest $request, Invoice $invoice)
{
$this->authorize('send invoice', $invoice);
$this->invoiceService->send($invoice, $request->all());
return response()->json([
'success' => true,
]);
}
public function sendPreview(SendInvoiceRequest $request, Invoice $invoice)
{
$this->authorize('send invoice', $invoice);
$markdown = new Markdown(view(), config('mail.markdown'));
$data = $this->invoiceService->sendInvoiceData($invoice, $request->all());
$data['url'] = $invoice->invoicePdfUrl;
return $markdown->render('emails.send.invoice', ['data' => $data]);
}
public function clone(Request $request, Invoice $invoice)
{
$this->authorize('view', $invoice);
$this->authorize('create', Invoice::class);
$newInvoice = $this->invoiceService->clone($invoice);
return new InvoiceResource($newInvoice);
}
public function convertToEstimate(Request $request, Invoice $invoice)
{
$this->authorize('create', Estimate::class);
$estimate = $this->invoiceService->convertToEstimate($invoice);
return new EstimateResource($estimate);
}
public function changeStatus(Request $request, Invoice $invoice)
{
$this->authorize('send invoice', $invoice);
$this->invoiceService->changeStatus($invoice, $request->status);
return response()->json([
'success' => true,

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\Item;
namespace App\Http\Controllers\Company\Item;
use App\Http\Controllers\Controller;
use App\Http\Requests;
@@ -8,11 +8,16 @@ use App\Http\Requests\DeleteItemsRequest;
use App\Http\Resources\ItemResource;
use App\Models\Item;
use App\Models\TaxType;
use App\Services\ItemService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ItemsController extends Controller
{
public function __construct(
private readonly ItemService $itemService,
) {}
/**
* Retrieve a list of existing Items.
*
@@ -48,7 +53,7 @@ class ItemsController extends Controller
{
$this->authorize('create', Item::class);
$item = Item::createItem($request);
$item = $this->itemService->create($request);
return new ItemResource($item);
}
@@ -75,7 +80,7 @@ class ItemsController extends Controller
{
$this->authorize('update', $item);
$item = $item->updateItem($request);
$item = $this->itemService->update($item, $request);
return new ItemResource($item);
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\Item;
namespace App\Http\Controllers\Company\Item;
use App\Http\Controllers\Controller;
use App\Http\Requests\UnitRequest;

View File

@@ -1,17 +1,22 @@
<?php
namespace App\Http\Controllers\V1\Admin\Users;
namespace App\Http\Controllers\Company\Members;
use App\Http\Controllers\Controller;
use App\Http\Requests\DeleteUserRequest;
use App\Http\Requests\UserRequest;
use App\Http\Requests\DeleteMemberRequest;
use App\Http\Requests\MemberRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Services\MemberService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UsersController extends Controller
class MembersController extends Controller
{
public function __construct(
private readonly MemberService $memberService,
) {}
/**
* Display a listing of the resource.
*
@@ -40,14 +45,13 @@ class UsersController extends Controller
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\UserRequest $request
* @return JsonResponse
*/
public function store(UserRequest $request)
public function store(MemberRequest $request)
{
$this->authorize('create', User::class);
$user = User::createFromRequest($request);
$user = $this->memberService->create($request);
return new UserResource($user);
}
@@ -67,14 +71,13 @@ class UsersController extends Controller
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\UserRequest $request
* @return JsonResponse
*/
public function update(UserRequest $request, User $user)
public function update(MemberRequest $request, User $user)
{
$this->authorize('update', $user);
$user->updateFromRequest($request);
$this->memberService->update($user, $request);
return new UserResource($user);
}
@@ -85,12 +88,12 @@ class UsersController extends Controller
* @param Request $request
* @return JsonResponse
*/
public function delete(DeleteUserRequest $request)
public function delete(DeleteMemberRequest $request)
{
$this->authorize('delete multiple users', User::class);
if ($request->users) {
User::deleteUsers($request->users);
$this->memberService->delete($request->users);
}
return response()->json([

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers\Company\Modules;
use App\Http\Controllers\Controller;
use App\Models\Module;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;
use InvoiceShelf\Modules\Registry as ModuleRegistry;
/**
* Read-only company-context Active Modules index.
*
* Lists every module the super admin has activated on this instance
* (Module::enabled = true) and reports whether each one has registered a
* settings schema. The frontend uses this to render the company-context
* "Modules" landing page with a Settings button per active module.
*
* Activation is instance-global; per-company customization happens through
* settings (per CompanySetting under the module.{slug}.* prefix).
*
* Slug convention: nwidart stores the module's PascalCase class name in
* `modules.name` (e.g. "HelloWorld"), but URLs and registry keys use the
* kebab-case form ("hello-world") for readability. We normalize via
* Str::kebab() so module authors can call Registry::registerMenu('hello-world')
* naturally without thinking about the storage format.
*/
class CompanyModulesController extends Controller
{
public function index(): JsonResponse
{
$this->authorize('manage modules');
$modules = Module::query()
->where('enabled', true)
->get()
->map(function (Module $module) {
$slug = Str::kebab($module->name);
$menu = ModuleRegistry::menuFor($slug);
$translatedMenuTitle = $this->translateMenuTitle($menu['title'] ?? null);
$displayName = $translatedMenuTitle ?? Str::headline($module->name);
return [
'slug' => $slug,
'name' => $module->name,
'display_name' => $displayName,
'version' => $module->version,
'has_settings' => ModuleRegistry::settingsFor($slug) !== null,
'menu' => $menu === null
? null
: [
...$menu,
'title' => $translatedMenuTitle ?? $menu['title'],
],
];
})
->values();
return response()->json(['data' => $modules]);
}
private function translateMenuTitle(?string $title): ?string
{
if ($title === null) {
return null;
}
$translatedTitle = __($title);
if (! is_string($translatedTitle) || $translatedTitle === $title) {
return null;
}
return $translatedTitle;
}
}

View File

@@ -0,0 +1,176 @@
<?php
namespace App\Http\Controllers\Company\Modules;
use App\Http\Controllers\Controller;
use App\Models\CompanySetting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use InvoiceShelf\Modules\Registry as ModuleRegistry;
use InvoiceShelf\Modules\Settings\Schema;
/**
* Schema-driven module settings backend.
*
* Each active module's ServiceProvider::boot() calls
* Registry::registerSettings($slug, $schema) once at app boot. This controller
* exposes that schema to the frontend, validates submitted values against the
* schema's per-field rules, and persists per-company values into CompanySetting
* under the key prefix `module.{slug}.{field_key}`.
*
* Activation is instance-global, but settings are per-company — two companies
* on the same instance can configure the same activated module differently.
*/
class ModuleSettingsController extends Controller
{
public function show(Request $request, string $slug): JsonResponse
{
$this->authorize('manage modules');
$schema = ModuleRegistry::settingsFor($slug);
if ($schema === null) {
abort(404, "Module '{$slug}' has not registered a settings schema.");
}
$values = collect($schema->fields())
->mapWithKeys(fn (array $field) => [
$field['key'] => CompanySetting::getSetting(
"module.{$slug}.{$field['key']}",
$request->header('company')
) ?? $field['default'],
])
->all();
return response()->json([
'schema' => $this->translateSchema($schema->toArray()),
'values' => $values,
]);
}
public function update(Request $request, string $slug): JsonResponse
{
$this->authorize('manage modules');
$schema = ModuleRegistry::settingsFor($slug);
if ($schema === null) {
abort(404, "Module '{$slug}' has not registered a settings schema.");
}
$rules = $this->buildRules($schema);
$allowedKeys = array_keys($rules);
$validated = $request->validate($rules);
$companyId = $request->header('company');
// Only persist keys the schema knows about — silently drop unknown keys
// rather than letting modules write arbitrary settings.
$settingsToWrite = [];
foreach ($allowedKeys as $key) {
if (array_key_exists($key, $validated)) {
$settingsToWrite["module.{$slug}.{$key}"] = $this->normalizeForStorage($validated[$key]);
}
}
if ($settingsToWrite !== []) {
CompanySetting::setSettings($settingsToWrite, $companyId);
}
return response()->json(['success' => true]);
}
/**
* Convert a Schema's field rule arrays into a flat Laravel validator rules array.
*
* Field rules are passed through verbatim — a field declared as
* `'rules' => ['required', 'string', 'max:255']` becomes
* `['my_field' => ['required', 'string', 'max:255']]`. The frontend's
* BaseSchemaForm.vue understands a subset of these for client-side validation;
* the backend validator is the source of truth.
*
* @return array<string, array<int, string>>
*/
private function buildRules(Schema $schema): array
{
$rules = [];
foreach ($schema->fields() as $field) {
$rules[$field['key']] = $this->withTypeRule($field);
}
return $rules;
}
/**
* Prepend a sensible per-type validation rule so booleans must be booleans,
* numbers must be numeric, etc., even if the module didn't declare it.
*
* @param array<string, mixed> $field
* @return array<int, string>
*/
private function withTypeRule(array $field): array
{
/** @var array<int, string> $declared */
$declared = $field['rules'] ?? [];
$typeRule = match ($field['type']) {
'switch' => 'boolean',
'number' => 'numeric',
'multiselect' => 'array',
default => 'nullable',
};
// Avoid duplicating the type rule if the module already declared it
if (in_array($typeRule, $declared, true)) {
return $declared;
}
return array_merge([$typeRule], $declared);
}
/**
* CompanySetting stores everything as strings. Cast booleans, ints, and
* arrays to a representation that round-trips through getSetting/setSetting
* without losing information. Reads happen in show() above and naturally
* return strings; the frontend handles re-coercion in BaseSchemaForm.vue.
*/
/**
* Translate section titles and field labels in the schema so the
* frontend receives ready-to-display strings instead of Laravel
* translation keys it cannot resolve (e.g. `helloworld::settings.greeting`).
*
* @param array{sections: list<array<string, mixed>>} $schema
* @return array{sections: list<array<string, mixed>>}
*/
private function translateSchema(array $schema): array
{
foreach ($schema['sections'] as &$section) {
if (isset($section['title'])) {
$section['title'] = __($section['title']);
}
foreach ($section['fields'] as &$field) {
if (isset($field['label'])) {
$field['label'] = __($field['label']);
}
}
}
return $schema;
}
private function normalizeForStorage(mixed $value): string
{
if (is_bool($value)) {
return $value ? '1' : '0';
}
if (is_array($value)) {
return json_encode($value, JSON_UNESCAPED_SLASHES) ?: '[]';
}
return (string) ($value ?? '');
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\Payment;
namespace App\Http\Controllers\Company\Payment;
use App\Http\Controllers\Controller;
use App\Http\Requests\PaymentMethodRequest;

View File

@@ -1,17 +1,24 @@
<?php
namespace App\Http\Controllers\V1\Admin\Payment;
namespace App\Http\Controllers\Company\Payment;
use App\Http\Controllers\Controller;
use App\Http\Requests\DeletePaymentsRequest;
use App\Http\Requests\PaymentRequest;
use App\Http\Requests\SendPaymentRequest;
use App\Http\Resources\PaymentResource;
use App\Models\Payment;
use App\Services\PaymentService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Mail\Markdown;
class PaymentsController extends Controller
{
public function __construct(
private readonly PaymentService $paymentService,
) {}
/**
* Display a listing of the resource.
*
@@ -48,7 +55,7 @@ class PaymentsController extends Controller
{
$this->authorize('create', Payment::class);
$payment = Payment::createPayment($request);
$payment = $this->paymentService->create($request);
return new PaymentResource($payment);
}
@@ -64,7 +71,7 @@ class PaymentsController extends Controller
{
$this->authorize('update', $payment);
$payment = $payment->updatePayment($request);
$payment = $this->paymentService->update($payment, $request);
return new PaymentResource($payment);
}
@@ -77,10 +84,31 @@ class PaymentsController extends Controller
->whereIn('id', $request->ids)
->pluck('id');
Payment::deletePayments($ids);
$this->paymentService->delete($ids);
return response()->json([
'success' => true,
]);
}
public function send(SendPaymentRequest $request, Payment $payment)
{
$this->authorize('send payment', $payment);
$response = $this->paymentService->send($payment, $request->all());
return response()->json($response);
}
public function sendPreview(Request $request, Payment $payment)
{
$this->authorize('send payment', $payment);
$markdown = new Markdown(view(), config('mail.markdown'));
$data = $this->paymentService->sendPaymentData($payment, $request->all());
$data['url'] = $payment->paymentPdfUrl;
return $markdown->render('emails.send.payment', ['data' => $data]);
}
}

View File

@@ -1,16 +1,21 @@
<?php
namespace App\Http\Controllers\V1\Admin\RecurringInvoice;
namespace App\Http\Controllers\Company\RecurringInvoice;
use App\Http\Controllers\Controller;
use App\Http\Requests\RecurringInvoiceRequest;
use App\Http\Resources\RecurringInvoiceResource;
use App\Models\RecurringInvoice;
use App\Services\RecurringInvoiceService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class RecurringInvoiceController extends Controller
{
public function __construct(
private readonly RecurringInvoiceService $recurringInvoiceService,
) {}
/**
* Display a listing of the resource.
*
@@ -42,7 +47,7 @@ class RecurringInvoiceController extends Controller
{
$this->authorize('create', RecurringInvoice::class);
$recurringInvoice = RecurringInvoice::createFromRequest($request);
$recurringInvoice = $this->recurringInvoiceService->create($request);
return new RecurringInvoiceResource($recurringInvoice);
}
@@ -69,7 +74,7 @@ class RecurringInvoiceController extends Controller
{
$this->authorize('update', $recurringInvoice);
$recurringInvoice->updateFromRequest($request);
$this->recurringInvoiceService->update($recurringInvoice, $request);
return new RecurringInvoiceResource($recurringInvoice);
}
@@ -88,7 +93,7 @@ class RecurringInvoiceController extends Controller
->whereIn('id', $request->ids)
->pluck('id');
RecurringInvoice::deleteRecurringInvoice($ids);
$this->recurringInvoiceService->delete($ids);
return response()->json([
'success' => true,

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\RecurringInvoice;
namespace App\Http\Controllers\Company\RecurringInvoice;
use App\Http\Controllers\Controller;
use App\Models\RecurringInvoice;

View File

@@ -1,7 +1,8 @@
<?php
namespace App\Http\Controllers\V1\Admin\Report;
namespace App\Http\Controllers\Company\Report;
use App\Facades\Pdf;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Models\CompanySetting;
@@ -11,7 +12,6 @@ use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use PDF;
class CustomerSalesReportController extends Controller
{
@@ -85,7 +85,7 @@ class CustomerSalesReportController extends Controller
'currency' => $currency,
]);
$pdf = PDF::loadView('app.pdf.reports.sales-customers');
$pdf = Pdf::loadView('app.pdf.reports.sales-customers');
if ($request->has('preview')) {
return view('app.pdf.reports.sales-customers');

View File

@@ -1,17 +1,18 @@
<?php
namespace App\Http\Controllers\V1\Admin\Report;
namespace App\Http\Controllers\Company\Report;
use App\Facades\Pdf;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Models\CompanySetting;
use App\Models\Currency;
use App\Models\Expense;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\App;
use PDF;
class ExpensesReportController extends Controller
{
@@ -19,7 +20,7 @@ class ExpensesReportController extends Controller
* Handle the incoming request.
*
* @param string $hash
* @return JsonResponse
* @return View|Response
*/
public function __invoke(Request $request, $hash)
{
@@ -31,14 +32,26 @@ class ExpensesReportController extends Controller
App::setLocale($locale);
$expenseCategories = Expense::with('category')
// Fetch individual expenses (filtered and ordered by date), then group by category
$expenses = Expense::with('category')
->whereCompanyId($company->id)
->applyFilters($request->only(['from_date', 'to_date']))
->expensesAttributes()
->applyFilters($request->only(['from_date', 'to_date', 'expense_category_id']))
->orderBy('expense_date', 'asc')
->get();
$totalAmount = 0;
foreach ($expenseCategories as $category) {
$totalAmount += $category->total_amount;
$totalAmount = $expenses->sum('base_amount');
$grouped = $expenses->groupBy(function ($item) {
return $item->category ? $item->category->name : trans('expenses.uncategorized');
});
$expenseGroups = collect();
foreach ($grouped as $categoryName => $group) {
$expenseGroups->push([
'name' => $categoryName,
'expenses' => $group,
'total' => $group->sum('base_amount'),
]);
}
$dateFormat = CompanySetting::getSetting('carbon_date_format', $company->id);
@@ -62,7 +75,7 @@ class ExpensesReportController extends Controller
->get();
view()->share([
'expenseCategories' => $expenseCategories,
'expenseGroups' => $expenseGroups,
'colorSettings' => $colorSettings,
'totalExpense' => $totalAmount,
'company' => $company,
@@ -70,7 +83,7 @@ class ExpensesReportController extends Controller
'to_date' => $to_date,
'currency' => $currency,
]);
$pdf = PDF::loadView('app.pdf.reports.expenses');
$pdf = Pdf::loadView('app.pdf.reports.expenses');
if ($request->has('preview')) {
return view('app.pdf.reports.expenses');

View File

@@ -1,7 +1,8 @@
<?php
namespace App\Http\Controllers\V1\Admin\Report;
namespace App\Http\Controllers\Company\Report;
use App\Facades\Pdf;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Models\CompanySetting;
@@ -11,7 +12,6 @@ use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use PDF;
class ItemSalesReportController extends Controller
{
@@ -70,7 +70,7 @@ class ItemSalesReportController extends Controller
'to_date' => $to_date,
'currency' => $currency,
]);
$pdf = PDF::loadView('app.pdf.reports.sales-items');
$pdf = Pdf::loadView('app.pdf.reports.sales-items');
if ($request->has('preview')) {
return view('app.pdf.reports.sales-items');

View File

@@ -1,7 +1,8 @@
<?php
namespace App\Http\Controllers\V1\Admin\Report;
namespace App\Http\Controllers\Company\Report;
use App\Facades\Pdf;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Models\CompanySetting;
@@ -12,7 +13,6 @@ use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use PDF;
class ProfitLossReportController extends Controller
{
@@ -78,7 +78,7 @@ class ProfitLossReportController extends Controller
'to_date' => $to_date,
'currency' => $currency,
]);
$pdf = PDF::loadView('app.pdf.reports.profit-loss');
$pdf = Pdf::loadView('app.pdf.reports.profit-loss');
if ($request->has('preview')) {
return view('app.pdf.reports.profit-loss');

View File

@@ -1,7 +1,8 @@
<?php
namespace App\Http\Controllers\V1\Admin\Report;
namespace App\Http\Controllers\Company\Report;
use App\Facades\Pdf;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Models\CompanySetting;
@@ -11,7 +12,6 @@ use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use PDF;
class TaxSummaryReportController extends Controller
{
@@ -73,7 +73,7 @@ class TaxSummaryReportController extends Controller
'currency' => $currency,
]);
$pdf = PDF::loadView('app.pdf.reports.tax-summary');
$pdf = Pdf::loadView('app.pdf.reports.tax-summary');
if ($request->has('preview')) {
return view('app.pdf.reports.tax-summary');

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\Role;
namespace App\Http\Controllers\Company\Role;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\Role;
namespace App\Http\Controllers\Company\Role;
use App\Http\Controllers\Controller;
use App\Http\Requests\RoleRequest;

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Company\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\CompanyLogoRequest;
use App\Http\Requests\CompanyRequest;
use App\Http\Resources\CompanyResource;
use App\Models\Company;
class CompanyController extends Controller
{
public function updateCompany(CompanyRequest $request)
{
$company = Company::find($request->header('company'));
$this->authorize('manage company', $company);
$company->update($request->getCompanyPayload());
$company->address()->updateOrCreate(['company_id' => $company->id], $request->address);
return new CompanyResource($company);
}
public function uploadCompanyLogo(CompanyLogoRequest $request)
{
$company = Company::find($request->header('company'));
$this->authorize('manage company', $company);
$data = json_decode($request->company_logo);
if (isset($request->is_company_logo_removed) && (bool) $request->is_company_logo_removed) {
$company->clearMediaCollection('logo');
}
if ($data) {
$company = Company::find($request->header('company'));
if ($company) {
$company->clearMediaCollection('logo');
$company->addMediaFromBase64($data->data)
->usingFileName($data->name)
->toMediaCollection('logo');
}
}
return response()->json([
'success' => true,
]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Company\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\CompanyMailConfigurationRequest;
use App\Mail\TestMail;
use App\Services\CompanyMailConfigService;
use App\Services\MailConfigurationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
class CompanyMailConfigurationController extends Controller
{
public function __construct(private readonly MailConfigurationService $mailConfigurationService) {}
public function getDefaultConfig(Request $request): JsonResponse
{
return response()->json($this->mailConfigurationService->getDefaultConfig());
}
public function getMailConfig(Request $request): JsonResponse
{
return response()->json(
$this->mailConfigurationService->getCompanyConfig($request->header('company'))
);
}
public function saveMailConfig(CompanyMailConfigurationRequest $request): JsonResponse
{
$this->authorize('owner only');
$this->mailConfigurationService->saveCompanyConfig(
$request->header('company'),
$request->validated()
);
return response()->json(['success' => true]);
}
public function testMailConfig(Request $request): JsonResponse
{
$this->authorize('owner only');
$this->validate($request, [
'to' => 'required|email',
'subject' => 'required',
'message' => 'required',
]);
CompanyMailConfigService::apply($request->header('company'));
Mail::to($request->to)->send(new TestMail($request->subject, $request->message));
return response()->json(['success' => true]);
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers\Company\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\GetSettingsRequest;
use App\Http\Requests\UpdateSettingsRequest;
use App\Models\Company;
use App\Models\CompanySetting;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Silber\Bouncer\BouncerFacade;
class CompanySettingsController extends Controller
{
public function show(GetSettingsRequest $request): JsonResponse
{
$settings = CompanySetting::getSettings((array) $request->settings, $request->header('company'));
return response()->json($settings);
}
public function update(UpdateSettingsRequest $request): JsonResponse
{
$company = Company::find($request->header('company'));
$this->authorize('manage company', $company);
$data = $request->settings;
if (
Arr::exists($data, 'currency') &&
(CompanySetting::getSetting('currency', $company->id) !== $data['currency']) &&
$company->hasTransactions()
) {
return response()->json([
'success' => false,
'message' => 'Cannot update company currency after transactions are created.',
]);
}
CompanySetting::setSettings($data, $request->header('company'));
return response()->json([
'success' => true,
]);
}
public function checkTransactions(Request $request): JsonResponse
{
$company = Company::find($request->header('company'));
$this->authorize('manage company', $company);
return response()->json([
'has_transactions' => $company->hasTransactions(),
]);
}
public function transferOwnership(Request $request, User $user): JsonResponse
{
$company = Company::find($request->header('company'));
$this->authorize('transfer company ownership', $company);
if (! $user->hasCompany($company->id)) {
return response()->json([
'success' => false,
'message' => 'User does not belong to this company.',
]);
}
$company->update(['owner_id' => $user->id]);
BouncerFacade::scope()->to($company->id);
BouncerFacade::sync($user)->roles(['owner']);
return response()->json([
'success' => true,
]);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Company\Settings;
use App\Http\Controllers\Controller;
use App\Http\Resources\CompanyInvitationResource;
use App\Models\Company;
use App\Models\CompanyInvitation;
use App\Services\InvitationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class InvitationController extends Controller
{
public function __construct(
private readonly InvitationService $invitationService,
) {}
public function index(Request $request): JsonResponse
{
$company = Company::find($request->header('company'));
$invitations = CompanyInvitation::where('company_id', $company->id)
->pending()
->with(['role', 'invitedBy'])
->latest()
->get();
return response()->json([
'invitations' => CompanyInvitationResource::collection($invitations),
]);
}
public function store(Request $request): JsonResponse
{
$request->validate([
'email' => 'required|email',
'role_id' => 'required|exists:roles,id',
]);
$company = Company::find($request->header('company'));
$invitation = $this->invitationService->invite(
$company,
$request->email,
$request->role_id,
$request->user()
);
return response()->json([
'success' => true,
'invitation' => new CompanyInvitationResource($invitation->load(['company', 'role', 'invitedBy'])),
]);
}
public function destroy(CompanyInvitation $companyInvitation): JsonResponse
{
if ($companyInvitation->status !== CompanyInvitation::STATUS_PENDING) {
return response()->json([
'success' => false,
'message' => 'Only pending invitations can be cancelled.',
], 422);
}
$companyInvitation->delete();
return response()->json(['success' => true]);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Admin\Settings;
namespace App\Http\Controllers\Company\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\TaxTypeRequest;

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers\Company\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\AvatarRequest;
use App\Http\Requests\GetSettingsRequest;
use App\Http\Requests\ProfileRequest;
use App\Http\Requests\UpdateSettingsRequest;
use App\Http\Resources\UserResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UserProfileController extends Controller
{
public function show(Request $request)
{
return new UserResource($request->user());
}
public function update(ProfileRequest $request)
{
$user = $request->user();
$user->update($request->validated());
return new UserResource($user);
}
public function uploadAvatar(AvatarRequest $request)
{
$user = auth()->user();
if (isset($request->is_admin_avatar_removed) && (bool) $request->is_admin_avatar_removed) {
$user->clearMediaCollection('admin_avatar');
}
if ($user && $request->hasFile('admin_avatar')) {
$user->clearMediaCollection('admin_avatar');
$user->addMediaFromRequest('admin_avatar')
->toMediaCollection('admin_avatar');
}
if ($user && $request->has('avatar')) {
$data = json_decode($request->avatar);
$user->clearMediaCollection('admin_avatar');
$user->addMediaFromBase64($data->data)
->usingFileName($data->name)
->toMediaCollection('admin_avatar');
}
return new UserResource($user);
}
public function showSettings(GetSettingsRequest $request): JsonResponse
{
$user = $request->user();
return response()->json($user->getSettings((array) $request->settings));
}
public function updateSettings(UpdateSettingsRequest $request): JsonResponse
{
$user = $request->user();
$user->setSettings($request->settings);
return response()->json([
'success' => true,
]);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Customer\Auth;
namespace App\Http\Controllers\CustomerPortal\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Customer\Auth;
namespace App\Http\Controllers\CustomerPortal\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Customer\CustomerLoginRequest;
@@ -20,7 +20,7 @@ class LoginController extends Controller
*/
public function __invoke(CustomerLoginRequest $request, Company $company)
{
$user = Customer::where('email', $request->email)
$user = Customer::whereRaw('LOWER(email) = ?', [strtolower($request->email)])
->where('company_id', $company->id)
->first();

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Customer\Auth;
namespace App\Http\Controllers\CustomerPortal\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Customer\Estimate;
namespace App\Http\Controllers\CustomerPortal\Estimate;
use App\Http\Controllers\Controller;
use App\Http\Resources\Customer\EstimateResource;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Customer\Estimate;
namespace App\Http\Controllers\CustomerPortal\Estimate;
use App\Http\Controllers\Controller;
use App\Http\Resources\Customer\EstimateResource;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Customer;
namespace App\Http\Controllers\CustomerPortal;
use App\Http\Controllers\Controller;
use App\Http\Resources\EstimateResource;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Customer\Expense;
namespace App\Http\Controllers\CustomerPortal\Expense;
use App\Http\Controllers\Controller;
use App\Http\Resources\Customer\ExpenseResource;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Customer\General;
namespace App\Http\Controllers\CustomerPortal\General;
use App\Http\Controllers\Controller;
use App\Http\Resources\Customer\CustomerResource;
@@ -31,10 +31,13 @@ class BootstrapController extends Controller
}
}
$companyCurrencyId = CompanySetting::getSetting('currency', $customer->company_id);
return (new CustomerResource($customer))
->additional(['meta' => [
'menu' => $menu,
'current_customer_currency' => Currency::find($customer->currency_id),
'current_company_currency' => $companyCurrencyId ? Currency::find($companyCurrencyId) : null,
'modules' => Module::where('enabled', true)->pluck('name'),
'current_company_language' => CompanySetting::getSetting('language', $customer->company_id),
]]);

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Customer\General;
namespace App\Http\Controllers\CustomerPortal\General;
use App\Http\Controllers\Controller;
use App\Models\Estimate;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Customer\General;
namespace App\Http\Controllers\CustomerPortal\General;
use App\Http\Controllers\Controller;
use App\Http\Requests\Customer\CustomerProfileRequest;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Customer\Invoice;
namespace App\Http\Controllers\CustomerPortal\Invoice;
use App\Http\Controllers\Controller;
use App\Http\Resources\Customer\InvoiceResource;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Customer;
namespace App\Http\Controllers\CustomerPortal;
use App\Http\Controllers\Controller;
use App\Http\Resources\Customer\InvoiceResource as CustomerInvoiceResource;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Customer\Payment;
namespace App\Http\Controllers\CustomerPortal\Payment;
use App\Http\Controllers\Controller;
use App\Http\Resources\Customer\PaymentMethodResource;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Customer\Payment;
namespace App\Http\Controllers\CustomerPortal\Payment;
use App\Http\Controllers\Controller;
use App\Http\Resources\Customer\PaymentResource;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V1\Customer;
namespace App\Http\Controllers\CustomerPortal;
use App\Http\Controllers\Controller;
use App\Http\Resources\PaymentResource;

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Modules;
use App\Http\Controllers\Controller;
use DateTime;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use InvoiceShelf\Modules\Registry as ModuleRegistry;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ScriptController extends Controller
{
/**
* Serve the requested module-registered script.
*
* Modules call \InvoiceShelf\Modules\Registry::registerScript($name, $path)
* from their ServiceProvider::boot() to inject custom JS into the host app.
*
* @throws NotFoundHttpException
*/
public function __invoke(Request $request, string $script): Response
{
$path = ModuleRegistry::scriptFor($script);
abort_if($path === null, 404);
return response(
file_get_contents($path),
200,
[
'Content-Type' => 'application/javascript',
]
)->setLastModified(DateTime::createFromFormat('U', (string) filemtime($path)));
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Modules;
use App\Http\Controllers\Controller;
use DateTime;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use InvoiceShelf\Modules\Registry as ModuleRegistry;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class StyleController extends Controller
{
/**
* Serve the requested module-registered stylesheet.
*
* Modules call \InvoiceShelf\Modules\Registry::registerStyle($name, $path)
* from their ServiceProvider::boot() to inject custom CSS into the host app.
*
* @throws NotFoundHttpException
*/
public function __invoke(Request $request, string $style): Response
{
$path = ModuleRegistry::styleFor($style);
abort_if($path === null, 404);
return response(
file_get_contents($path),
200,
[
'Content-Type' => 'text/css',
]
)->setLastModified(DateTime::createFromFormat('U', (string) filemtime($path)));
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Pdf;
use App\Http\Controllers\Controller;
use App\Models\Estimate;
use App\Models\Invoice;
use App\Models\Payment;
use App\Services\EstimateService;
use App\Services\InvoiceService;
use Illuminate\Http\Request;
class DocumentPdfController extends Controller
{
public function __construct(
private readonly InvoiceService $invoiceService,
private readonly EstimateService $estimateService,
) {}
public function invoice(Request $request, Invoice $invoice)
{
if ($request->has('preview')) {
return $this->invoiceService->getPdfData($invoice);
}
return $invoice->getGeneratedPDFOrStream('invoice');
}
public function estimate(Request $request, Estimate $estimate)
{
if ($request->has('preview')) {
return $this->estimateService->getPdfData($estimate);
}
return $estimate->getGeneratedPDFOrStream('estimate');
}
public function payment(Request $request, Payment $payment)
{
if ($request->has('preview')) {
return view('app.pdf.payment.payment');
}
return $payment->getGeneratedPDFOrStream('payment');
}
}

View File

@@ -1,10 +1,10 @@
<?php
namespace App\Http\Controllers\V1\Installation;
namespace App\Http\Controllers\Setup;
use App\Http\Controllers\Controller;
use App\Http\Requests\DomainEnvironmentRequest;
use App\Space\EnvironmentManager;
use App\Services\Setup\EnvironmentManager;
use Illuminate\Support\Facades\Artisan;
class AppDomainController extends Controller

View File

@@ -1,11 +1,11 @@
<?php
namespace App\Http\Controllers\V1\Installation;
namespace App\Http\Controllers\Setup;
use App\Http\Controllers\Controller;
use App\Http\Requests\DatabaseEnvironmentRequest;
use App\Space\EnvironmentManager;
use App\Space\InstallUtils;
use App\Services\Setup\EnvironmentManager;
use App\Services\Setup\InstallUtils;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
@@ -54,7 +54,7 @@ class DatabaseConfigurationController extends Controller
case 'sqlite':
$databaseData = [
'database_connection' => 'sqlite',
'database_name' => config('database.connections.sqlite.database', storage_path('database.sqlite')),
'database_name' => config('database.connections.sqlite.database') ?: 'storage/app/database.sqlite',
];
break;

View File

@@ -1,9 +1,9 @@
<?php
namespace App\Http\Controllers\V1\Installation;
namespace App\Http\Controllers\Setup;
use App\Http\Controllers\Controller;
use App\Space\FilePermissionChecker;
use App\Services\Setup\FilePermissionChecker;
use Illuminate\Http\JsonResponse;
class FilePermissionsController extends Controller

Some files were not shown because too many files have changed in this diff Show More