Add PurchaseOrderSync classes

This commit is contained in:
David Bomba
2026-02-23 08:29:59 +11:00
parent 36550c26a5
commit b00011d972
14 changed files with 286 additions and 18 deletions

View File

@@ -43,6 +43,7 @@ class InvoiceSyncCast implements CastsAttributes
'qb_id' => $value->qb_id,
'invitations' => $value->invitations,
'dn_completed' => $value->dn_completed,
'dn_document_hashed_id' => $value->dn_document_hashed_id,
])
];
}

View File

@@ -0,0 +1,50 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Casts;
use App\DataMapper\PurchaseOrderSync;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class PurchaseOrderSyncCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes)
{
if (is_null($value)) {
return null; // Return null if the value is null
}
$data = json_decode($value, true);
if (!is_array($data) || empty($data)) {
return null; // Return null if decoded data is not an array or is empty
}
return PurchaseOrderSync::fromArray($data);
}
public function set($model, string $key, $value, array $attributes)
{
if (is_null($value)) {
return [$key => null];
}
return [
$key => json_encode([
'qb_id' => $value->qb_id,
'invitations' => $value->invitations,
'dn_completed' => $value->dn_completed,
'dn_document_hashed_id' => $value->dn_document_hashed_id,
])
];
}
}

View File

@@ -43,6 +43,7 @@ class QuoteSyncCast implements CastsAttributes
'qb_id' => $value->qb_id,
'invitations' => $value->invitations,
'dn_completed' => $value->dn_completed,
'dn_document_hashed_id' => $value->dn_document_hashed_id,
])
];
}

View File

@@ -26,6 +26,7 @@ class InvoiceSync implements Castable
public string $qb_id = '',
public array $invitations = [],
public bool $dn_completed = false,
public string $dn_document_hashed_id = '',
){}
/**
* Get the name of the caster class to use when casting from / to this cast target.
@@ -43,6 +44,7 @@ class InvoiceSync implements Castable
qb_id: $data['qb_id'] ?? '',
invitations: $data['invitations'] ?? [],
dn_completed: $data['dn_completed'] ?? false,
dn_document_hashed_id: $data['dn_document_hashed_id'] ?? '',
);
}

View File

@@ -0,0 +1,100 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\DataMapper;
use App\Casts\PurchaseOrderSyncCast;
use Illuminate\Contracts\Database\Eloquent\Castable;
/**
* PurchaseOrderSync.
*/
class PurchaseOrderSync implements Castable
{
public function __construct(
public string $qb_id = '',
public array $invitations = [],
public bool $dn_completed = false,
public string $dn_document_hashed_id = '',
){}
/**
* Get the name of the caster class to use when casting from / to this cast target.
*
* @param array<string, mixed> $arguments
*/
public static function castUsing(array $arguments): string
{
return PurchaseOrderSyncCast::class;
}
public static function fromArray(array $data): self
{
return new self(
qb_id: $data['qb_id'] ?? '',
invitations: $data['invitations'] ?? [],
dn_completed: $data['dn_completed'] ?? false,
dn_document_hashed_id: $data['dn_document_hashed_id'] ?? '',
);
}
/**
* Add an invitation to the invitations array
*
* @param string $invitation_key The invitation key
* @param string $dn_id The DocuNinja ID
* @param string $dn_invitation_id The DocuNinja invitation ID
* @param string $dn_sig The DocuNinja signature
*/
public function addInvitation(
string $invitation_key,
string $dn_id,
string $dn_invitation_id,
string $dn_sig
): void {
$this->invitations[] = [
'invitation_key' => $invitation_key,
'dn_id' => $dn_id,
'dn_invitation_id' => $dn_invitation_id,
'dn_sig' => $dn_sig,
];
}
/**
* Get invitation data by invitation key
*
* @param string $invitation_key The invitation key
* @return array|null The invitation data or null if not found
*/
public function getInvitation(string $invitation_key): ?array
{
foreach ($this->invitations as $invitation) {
if ($invitation['invitation_key'] === $invitation_key) {
return $invitation;
}
}
return null;
}
/**
* Remove an invitation by invitation key
*
* @param string $invitation_key The invitation key
*/
public function removeInvitation(string $invitation_key): void
{
$this->invitations = array_filter($this->invitations, function($invitation) use ($invitation_key) {
return $invitation['invitation_key'] !== $invitation_key;
});
// Re-index the array to maintain numeric keys
$this->invitations = array_values($this->invitations);
}
}

View File

@@ -24,6 +24,7 @@ class QuoteSync implements Castable
public string $qb_id = '',
public array $invitations = [],
public bool $dn_completed = false,
public string $dn_document_hashed_id = '',
){}
/**
* Get the name of the caster class to use when casting from / to this cast target.
@@ -41,6 +42,7 @@ class QuoteSync implements Castable
qb_id: $data['qb_id'] ?? '',
invitations: $data['invitations'] ?? [],
dn_completed: $data['dn_completed'] ?? false,
dn_document_hashed_id: $data['dn_document_hashed_id'] ?? '',
);
}

View File

@@ -101,6 +101,26 @@ class CreateRawPdf
public function handle()
{
/** Serve DocuNinja signed PDF if signing is complete */
if (in_array($this->entity_string, ['invoice', 'quote', 'purchase_order'])
&& $this->company->docuninjaActive()
&& $this->entity->sync?->dn_completed
) {
$document = $this->entity->getSignedPdfDocument();
if ($document) {
try {
$pdf = $document->getFile();
if ($pdf && strlen($pdf) > 0) {
return $pdf;
}
} catch (\Exception $e) {
nlog("Failed to retrieve signed PDF for {$this->entity_string} {$this->entity->id}: " . $e->getMessage());
}
}
}
$pdf = $this->generatePdf();
if ($this->isBlankPdf($pdf)) {

View File

@@ -5,6 +5,8 @@ namespace App\Livewire\Flow2;
use Livewire\Component;
use App\Libraries\MultiDB;
use App\DataMapper\InvoiceSync;
use App\DataMapper\PurchaseOrderSync;
use App\DataMapper\QuoteSync;
use App\Models\QuoteInvitation;
use App\Models\CreditInvitation;
use App\Models\InvoiceInvitation;
@@ -91,7 +93,11 @@ class DocuNinjaLoader extends Component
$signable = $invitation->{$this->entity_type}->service()->getDocuNinjaSignable($invitation);
$sync = new InvoiceSync(qb_id: '', dn_completed: false);
$sync = match($this->entity_type) {
'quote' => new QuoteSync(qb_id: '', dn_completed: false),
'purchase_order' => new PurchaseOrderSync(qb_id: '', dn_completed: false),
default => new InvoiceSync(qb_id: '', dn_completed: false),
};
$sync->addInvitation(
$signable['invitation_key'],
$signable['document_id'],

View File

@@ -395,6 +395,25 @@ class BaseModel extends Model
return \App\Services\Pdf\Purify::clean(html_entity_decode($parsed));
}
/**
* Retrieve the DocuNinja signed PDF document for this entity.
*
* Uses the dn_document_hashed_id stored on the entity's sync object
* to look up the Document record.
*
* @return \App\Models\Document|null
*/
public function getSignedPdfDocument(): ?\App\Models\Document
{
if (!$this->sync?->dn_document_hashed_id) {
return null;
}
return $this->documents()
->where('id', $this->decodePrimaryKey($this->sync->dn_document_hashed_id))
->first();
}
/**
* Merged PDFs associated with the entity / company
* into a single document

View File

@@ -15,7 +15,7 @@ namespace App\Models;
use App\Utils\Ninja;
use App\Utils\Number;
use Illuminate\Support\Carbon;
use App\DataMapper\InvoiceSync;
use App\DataMapper\PurchaseOrderSync;
use App\Helpers\Invoice\InvoiceSum;
use Illuminate\Support\Facades\App;
use Elastic\ScoutDriverPlus\Searchable;
@@ -113,7 +113,7 @@ use App\Events\PurchaseOrder\PurchaseOrderWasEmailed;
* @property object|null $tax_data
* @property object|null $e_invoice
* @property int|null $location_id
* @property \App\DataMapper\InvoiceSync|null $sync
* @property \App\DataMapper\PurchaseOrderSync|null $sync
* @method static \Illuminate\Database\Eloquent\Builder|PurchaseOrder exclude($columns)
* @method static \Database\Factories\PurchaseOrderFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder|PurchaseOrder filter(\App\Filters\QueryFilters $filters)
@@ -218,7 +218,7 @@ class PurchaseOrder extends BaseModel
'deleted_at' => 'timestamp',
'is_amount_discount' => 'bool',
'e_invoice' => 'object',
'sync' => InvoiceSync::class,
'sync' => PurchaseOrderSync::class,
];
public const STATUS_DRAFT = 1;

View File

@@ -273,7 +273,7 @@ class UserRepository extends BaseRepository
$notes = $user->present()->name();
\DB::transaction(function () use ($user, $new_owner_user, $notes) {
\DB::transaction(function () use ($user, $new_owner_user) {
// Relations to transfer user_id to new owner
$allRelations = [
@@ -314,8 +314,9 @@ class UserRepository extends BaseRepository
$user->forceDelete();
event(new UserWasPurged($new_owner_user, $notes, auth()->user()->company(), Ninja::eventVars(auth()->user() ? auth()->user()->id : null)));
});
$company = $new_owner_user->account->default_company ?? $new_owner_user->companies->first();
event(new UserWasPurged($new_owner_user, $notes, $company, Ninja::eventVars(auth()->user() ? auth()->user()->id : null)));
}
}

View File

@@ -118,7 +118,8 @@
"twig/twig": "^3.14",
"twilio/sdk": "^6.40",
"wikimedia/composer-merge-plugin": "^2.1",
"wildbit/postmark-php": "^4.0"
"wildbit/postmark-php": "^4.0",
"invoiceninja/admin-api": "dev-main"
},
"require-dev": {
"barryvdh/laravel-debugbar": "^3.6",
@@ -223,6 +224,10 @@
{
"type": "vcs",
"url": "https://github.com/turbo124/snappdf"
},
{
"type": "path",
"url": "../admin-api"
}
],
"minimum-stability": "dev",

82
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "1b74189cd9dbfc430f4a65c9c3e0f784",
"content-hash": "05003792e6ff08c3d34abc489a7003f1",
"packages": [
{
"name": "afosto/yaac",
@@ -4482,6 +4482,65 @@
],
"time": "2022-05-21T17:30:32+00:00"
},
{
"name": "invoiceninja/admin-api",
"version": "dev-main",
"dist": {
"type": "path",
"url": "../admin-api",
"reference": "520d230d7f022515fdc35d43ca9702ae886fba01"
},
"require": {
"afosto/yaac": "^1.5",
"asm/php-ansible": "dev-main",
"ext-curl": "*",
"ext-dom": "*",
"ext-json": "*",
"ext-libxml": "*",
"ext-simplexml": "*",
"illuminate/database": "^11",
"illuminate/support": "^11",
"imdhemy/laravel-purchases": "^1.7",
"php": "^8.2|^8.3|^8.4"
},
"require-dev": {
"larastan/larastan": "^3.0",
"orchestra/testbench": "^9.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^11.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"InvoiceNinja\\AdminApi\\Providers\\AdminApiServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"InvoiceNinja\\AdminApi\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"license": [
"Elastic"
],
"authors": [
{
"name": "David Bomba",
"email": "turbo124@gmail.com"
}
],
"description": "API endpoints for the admin interface",
"transport-options": {
"relative": true
}
},
{
"name": "invoiceninja/einvoice",
"version": "dev-main",
@@ -21697,23 +21756,23 @@
},
{
"name": "spatie/laravel-ignition",
"version": "2.10.0",
"version": "2.11.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-ignition.git",
"reference": "2abefdcca6074a9155f90b4ccb3345af8889d5f5"
"reference": "11f38d1ff7abc583a61c96bf3c1b03610a69cccd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/2abefdcca6074a9155f90b4ccb3345af8889d5f5",
"reference": "2abefdcca6074a9155f90b4ccb3345af8889d5f5",
"url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/11f38d1ff7abc583a61c96bf3c1b03610a69cccd",
"reference": "11f38d1ff7abc583a61c96bf3c1b03610a69cccd",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"illuminate/support": "^11.0|^12.0",
"illuminate/support": "^11.0|^12.0|^13.0",
"nesbot/carbon": "^2.72|^3.0",
"php": "^8.2",
"spatie/ignition": "^1.15.1",
@@ -21721,10 +21780,10 @@
"symfony/var-dumper": "^7.4|^8.0"
},
"require-dev": {
"livewire/livewire": "^3.7.0|^4.0",
"livewire/livewire": "^3.7.0|^4.0|dev-josh/v3-laravel-13-support",
"mockery/mockery": "^1.6.12",
"openai-php/client": "^0.10.3",
"orchestra/testbench": "^v9.16.0|^10.6",
"openai-php/client": "^0.10.3|^0.19",
"orchestra/testbench": "^v9.16.0|^10.6|^11.0",
"pestphp/pest": "^3.7|^4.0",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan-deprecation-rules": "^2.0.3",
@@ -21785,7 +21844,7 @@
"type": "github"
}
],
"time": "2026-01-20T13:16:11+00:00"
"time": "2026-02-22T19:14:05+00:00"
},
{
"name": "spaze/phpstan-stripe",
@@ -22104,7 +22163,8 @@
"asm/php-ansible": 20,
"beganovich/snappdf": 20,
"invoiceninja/einvoice": 20,
"socialiteproviders/apple": 20
"socialiteproviders/apple": 20,
"invoiceninja/admin-api": 20
},
"prefer-stable": true,
"prefer-lowest": false,

View File

@@ -929,6 +929,7 @@ class UserTest extends TestCase
// Perform the purge
$user_repo = new UserRepository();
$owner_user->setCompany($company);
$user_repo->purge($secondary_user, $owner_user);
// Assert secondary user is deleted