ituoiprofessionistidigitali / connector
Il contratto di I tuoi professionisti digitali in forma di codice: DTO, client, firma dei webhook e validazione dei payload.
Package info
github.com/2P2srl/ituoiprofessionistidigitali-connector
pkg:composer/ituoiprofessionistidigitali/connector
Requires
- php: ^8.4
- illuminate/console: ^13.0
- illuminate/database: ^13.0
- illuminate/http: ^13.0
- illuminate/queue: ^13.0
- illuminate/support: ^13.0
- opis/json-schema: ^2.6
- ramsey/uuid: ^4.7
- spatie/laravel-data: ^4.23
Requires (Dev)
- larastan/larastan: ^3.12
- laravel/pint: ^1.32
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0|^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-22 12:48:50 UTC
README
Il contratto di I tuoi professionisti digitali in forma di codice, per i sistemi Laravel che si collegano al portale: DTO, client, firma dei webhook, validazione dei payload e comando di installazione.
Il portale è l'autorità sul contratto. Il pacchetto lo segue: se un dato non rispetta il contratto, la richiesta non parte. Il contratto completo, con ogni regola e il suo codice (E6, W4…), è docs/contratto.md nel repo del portale.
Requisiti
PHP 8.4 o 8.5, Laravel 13.
Installazione
composer require ituoiprofessionistidigitali/connector php artisan platform:install
Il comando pubblica config/platform.php e deposita le istruzioni per gli agenti AI: in .ai/rules/platform-connector.md se il progetto usa quella convenzione, altrimenti in AGENTS-platform-connector.md. Dentro vendor/ nessun agente le leggerebbe.
Credenziali
Le rilascia l'operatore del portale, che le vede una volta sola:
PLATFORM_URL=https://staging.ituoiprofessionistidigitali.it PLATFORM_CLIENT_ID=0199b6ef-… PLATFORM_CLIENT_SECRET=… PLATFORM_SIGNING_SECRET=…
Senza queste chiavi il sistema non è collegato e nulla si rompe: ConnectorConfig::isConnected() dice se usare il portale.
| Chiave | Predefinito | Cosa fa |
|---|---|---|
PLATFORM_WEBHOOK_PATH |
platform/webhook |
Percorso della rotta che riceve gli eventi; vuoto per non registrarla |
PLATFORM_REPLY_TO_PINGS |
true |
Risponde da solo ai platform.ping degli aderenti |
PLATFORM_QUEUE |
coda predefinita | Coda della risposta ai ping |
PLATFORM_VALIDATE_PAYLOADS |
true |
Controlla il payload sullo schema del catalogo prima di mandarlo |
PLATFORM_CATALOG_TTL |
3600 |
Secondi di cache del catalogo usato dalla validazione |
PLATFORM_TIMEOUT, PLATFORM_RETRIES |
10, 2 |
Timeout in secondi e nuovi tentativi su errori 5xx e di rete |
Collegamento
use ITuoiProfessionistiDigitali\Connector\Facades\Platform; // Dopo il deploy, o quando cambia l'URL del webhook $system = Platform::present(route('platform.webhook')); $system->status; // SystemStatus::Pending finché il portale non ha verificato il webhook
La verifica la gestisce il pacchetto: la rotta platform.webhook risponde alla sfida del portale.
Aderenti
use ITuoiProfessionistiDigitali\Connector\Data\MemberData; $members = Platform::syncMembers([ MemberData::validateAndCreate([ 'external_ref' => 'struttura-1', 'subject_type' => 'organization', 'name' => 'Studio Rossi e Associati', 'vat_number' => '01234567897', 'tax_code' => null, 'municipality' => 'Bari', 'province' => 'BA', 'typologies' => ['commercialisti'], 'listed' => true, ]), ]); $members[0]->id; // l'id del portale: il sender dei tuoi eventi
L'elenco è completo: gli aderenti che mancano diventano inattivi.
Due syncMembers() contemporanei dello stesso sistema non si mescolano: il portale li applica uno dopo l'altro e vince l'ultimo (M6). Un rifiuto è una PlatformRequestException con le chiavi dei campi in ->errors, da correggere e non da ritentare.
$page = Platform::searchMembers(typology: 'commercialisti', search: 'bianchi'); foreach ($page->members as $member) { /* id, name, vat_number, municipality, province, typologies */ } $next = Platform::searchMembers(typology: 'commercialisti', cursor: $page->nextCursor);
searchMembers() non mostra mai il codice fiscale, external_ref né il sistema (M11, M12). Un aderente di un altro sistema si legge anche per id, per esempio il committente di un'offerta ricevuta (M16):
use ITuoiProfessionistiDigitali\Connector\Exceptions\MemberNotFoundException; $committente = Platform::member($envelope->sender); // ListedMemberData
Risponde per un aderente pubblicato di un altro sistema attivo, o legato al sistema da una transazione; ogni altro caso, compreso un aderente del sistema stesso, è MemberNotFoundException, senza dire perché.
Eventi
use ITuoiProfessionistiDigitali\Connector\Data\EnvelopeData; $ping = EnvelopeData::make( type: 'platform.ping', schemaVersion: 1, sender: $mioAderente, recipient: $altroAderente, typology: 'commercialisti', payload: ['challenge' => bin2hex(random_bytes(12))], ); $accepted = Platform::send($ping); // 202 la prima volta, 200 se la stessa busta era già arrivata
Salva la busta prima di mandarla: dopo un timeout si rimanda la stessa, con lo stesso event_id.
L'outbox degli eventi lo fa per te. Chiama queue() nella stessa transazione del database del cambio che produce l'evento: la busta parte solo se il cambio è salvato, dopo il commit, in coda, sempre identica a ogni tentativo.
use Illuminate\Support\Facades\DB; use ITuoiProfessionistiDigitali\Connector\Outbox\EventOutbox; DB::transaction(function () use ($busta, $incarico): void { $incarico->save(); resolve(EventOutbox::class)->queue($busta); });
- Il job
SendPlatformEventritenta da solo gli errori di rete, i 5xx e il 403 di un sistema non ancora attivo;platform:send-outboxrimette in coda ogni cinque minuti gli eventi rimastipending. - Un 422 o un 409, del portale o del pacchetto prima della richiesta, porta la riga di
platform_event_outboxinfailed, conlast_error: è un errore nel codice, da correggere, non da ritentare. - La stessa busta passata due volte si tiene una volta sola; lo stesso
event_idcon un altro contenuto è unaLogicException. - Nei test:
PlatformOutbox::assertEventQueued($tipo, fn (EnvelopeData $busta): bool => …)ePlatformOutbox::assertEventNotQueued($tipo). - La tabella è nuova: dopo l'aggiornamento del pacchetto lancia
php artisan migrate.
Per ricevere, ascolta PlatformEventReceived, in coda e in modo idempotente su event_id:
use Illuminate\Contracts\Queue\ShouldQueue; use ITuoiProfessionistiDigitali\Connector\Events\PlatformEventReceived; final class HandlePlatformEvent implements ShouldQueue { public function handle(PlatformEventReceived $event): void { $envelope = $event->envelope; // EnvelopeData con firma già verificata } }
Il portale non genera eventi di business: da sé manda solo il platform.ping della verifica, a cui risponde il pacchetto. Il catalogo tiene sent_by (EventTypeData::$sent_by) per i tipi che il portale potrà mandare in futuro, e Platform::send() rifiuta un tipo con sent_by: platform prima di mandarlo (E5). Ogni altra busta senza mittente viene da un portale più recente: il webhook risponde 202, la scrive nel log come warning (id, tipo e versione, mai il payload) e non la passa all'applicazione. Così il portale non la ripete per un giorno (W5, W6). Quando compare quel warning, aggiorna il pacchetto. Una busta che non si legge ha ancora 400.
Eventi fra studi
Un incarico affidato a uno studio di un altro sistema si porta avanti con eventi fra i due sistemi; il portale li controlla sul registro e li inoltra, ma non decide nulla (E13). Con A il sistema del committente e B quello dello studio:
- A registra la transazione
invited, con lo studio come controparte (CounterpartyData::member($idAderente)), e nella stessa transazione del database accodacoworking.assignment_offered. - B riceve l'offerta con la transazione letta dal registro (
$event->transaction), decide nel suo sistema e risponde una volta sola concoworking.assignment_acceptedocoworking.assignment_declined, con l'istante della risposta. - A, nel listener della risposta, registra la transazione
acceptedodeclinedconresponded_atuguale adanswered_at. Il portale rifiuta questo passaggio prima della risposta di B (R24). - Ognuno manda all'altro
coworking.contract_details; i due studi firmano fuori dal portale e A registrasigned_at. - A manda
coworking.activities_assigned; B mandacoworking.activity_status, che A riporta nei suoiPUT.
A può revocare finché l'incarico non è concluso, prima o dopo la risposta: registra revoked, con closed_at, e accoda coworking.assignment_revoked con revoked_at uguale a closed_at. Una risposta di B arrivata dopo la revoca registrata riceve 422: l'offerta non c'è più.
| Tipo | Payload | Chi lo manda |
|---|---|---|
coworking.assignment_offered |
AssignmentOfferedData: la transazione (TransactionIdData) |
Il committente, con il registro invited |
coworking.assignment_accepted |
AssignmentAcceptedData: la transazione e answered_at |
Lo studio, una volta, con il registro invited |
coworking.assignment_declined |
AssignmentDeclinedData: la transazione, answered_at e reason (fino a 1000 caratteri, o null) |
Lo studio, una volta, con il registro invited |
coworking.assignment_revoked |
AssignmentRevokedData: la transazione e revoked_at |
Il committente, con il registro già revoked |
coworking.contract_details |
ContractDetailsData: lo studio (PartyData, con la sede in AddressData) e chi firma (SignatoryData) |
Tutti e due, dopo l'accettazione |
coworking.activities_assigned |
ActivitiesAssignedData: i processi (AssignedProcessData) col cliente finale (FinalClientData), le attività (AssignedActivityData) e le guide (GuideData) |
Il committente, dopo la firma |
coworking.activity_status |
ActivityStatusData: un'attività completed o reopened (ActivityStatusChange), con l'istante |
Lo studio |
Ogni evento ha correlation_id uguale all'id della transazione e la sua tipologia. L'id lo dà il pacchetto alla transazione dal primo salvataggio: $model->platformTransactionId() (R23).
use ITuoiProfessionistiDigitali\Connector\Data\AssignmentOfferedData; use ITuoiProfessionistiDigitali\Connector\Outbox\EventOutbox; DB::transaction(function () use ($proposta): void { $proposta->save(); // RecordsOnPlatform: la transazione invited entra nella outbox, con il suo id $offerta = AssignmentOfferedData::from(['transaction' => ['id' => $proposta->platformTransactionId()]]) ->toEnvelope(sender: $mioAderente, recipient: $studio->platform_id, typology: $proposta->typology); resolve(EventOutbox::class)->queue($offerta); });
- Ordine. Un evento che cita una transazione della outbox delle transazioni parte solo dopo che il portale ne ha confermato l'ultima versione salvata: aspetta in
pending, con il motivo inlast_error, e la conferma lo rimette in coda. Se il portale ha rifiutato la transazione, anche l'evento finiscefailed. Salva il modello prima di accodare l'evento, nella stessa transazione del database. - Ricezione di un'offerta. Il webhook risponde subito; un job in coda (
ReceivePlatformEvent) legge la transazione conPlatform::transaction($id)e lanciaPlatformEventReceivedcon$event->transaction(RecordedTransactionData): i termini, i prezzi e la controparte come il registro li ha. Se il registro non la mostra ancora, il job ritenta. - La transazione è del committente: il sistema dello studio non la registra e non scrive nel registro; la sua risposta la registra il committente.
toEnvelope()controlla il payload sullo schema del pacchetto prima di costruire la busta: fuori schema è unaPlatformRequestException422, come la darebbe il portale.validateAndCreate()controlla anche il carattere di controllo di partita IVA e codici fiscali (M4) e il cellulare in E.164.- In
coworking.activities_assignedogni processo porta ilprocess.referencedelle sue attività nella transazione (K1): lo studio le raggruppa come il committente. - Ricevendoli, leggi il payload con il DTO del tipo:
AssignmentAcceptedData::from($envelope->payload).
File fra sistemi
Un evento fra studi può portare documenti, come gli allegati delle guide di coworking.activities_assigned. Il file non viaggia nella busta: sta sul portale per il tempo del transito, e l'evento lo cita con il suo id, nel payload e in files della busta (E12, F1–F11).
use ITuoiProfessionistiDigitali\Connector\Facades\Platform; $file = Platform::uploadFile( uploadId: (string) Str::uuid7(), // la chiave di idempotenza (F2): salvala per i nuovi tentativi sender: $mioAderente, recipient: $altroStudio, disk: 's3', path: 'guide/fatture.pdf', name: 'Guida registrazione fatture.pdf', mediaType: 'application/pdf', ); // FileData, uploaded, da citare entro citable_until (24 ore) Platform::fileStatus($file->id); // FileData con lo stato: uploaded, sent, received o expired (FileStatus)
uploadFile()legge il file dal disco del sistema e ne calcola lo SHA-256 senza tenerlo in memoria. Ripete da solo gli errori di rete, i 5xx e un 429 breve, riaprendo il file a ogni tentativo. Un 410 vuol dire unupload_idgià scaduto: ne serve uno nuovo.EnvelopeData::make(..., files: [$file->id])elenca i file della busta. I DTO deicoworking.*lo fanno da soli:toEnvelope()mette infilesgli id che il payload cita. Il pacchetto controlla prima dell'invio che siano esattamente quelli del payload, al massimo 20 (E12).- Ricevendo,
$envelope->fileReferences()dà i file della busta consegnata (FileReferenceData, con nome, tipo, dimensione, SHA-256 eavailable_until).Platform::downloadFile($riferimento, $disco, $percorso)scarica e salva solo se lo SHA-256 è quello della busta, altrimentiFileIntegrityException; un file scaduto o già confermato èFileGoneException.Platform::confirmFile($id, $sha256)conferma, e il portale cancella il file (F8). reply()non copia mai i file dell'evento a cui risponde.PLATFORM_FILE_TIMEOUT(predefinito 120 secondi) è il tempo massimo di un caricamento.
Con l'outbox degli eventi non serve caricare a mano. Ogni file del sistema è un OutgoingFileData con un ref provvisorio: citalo nel payload dove andrebbe l'id del file, e passa i file a queue().
use ITuoiProfessionistiDigitali\Connector\Data\OutgoingFileData; $allegato = OutgoingFileData::make('s3', 'guide/fatture.pdf', 'Guida registrazione fatture.pdf', 'application/pdf'); $busta = ActivitiesAssignedData::validateAndCreate([ // ... 'guides' => [['title' => 'Registrazione fatture', 'body' => $testo, 'attachments' => [$allegato->ref]]], ])->toEnvelope($mioAderente, $studioScelto, $transazione->typology); DB::transaction(function () use ($busta, $allegato): void { // il cambio che produce l'evento, poi: resolve(EventOutbox::class)->queue($busta, [$allegato]); });
queue()legge lo SHA-256 di ogni file subito, e salva i file inplatform_event_outbox_files(una tabella nuova:php artisan migrate). I file della busta devono essere esattamente irefpassati, altrimentiLogicException.- Il job carica i file e manda l'evento con gli id del portale al posto dei
ref, nel payload e infiles. Dopo un tentativo che può essere arrivato chiede lo stato dei file (F5):sentvuol dire che l'evento è arrivato, e rimanda la stessa busta;expiredli ricarica con unupload_idnuovo. - Ricevendo, il pacchetto fa tutto da solo. Un evento con file ha subito il suo 2xx dal webhook, e un job in coda (
ReceivePlatformEvent, uno perevent_id) scarica ogni file inplatform-files/{event_id}/{file_id}sul discoPLATFORM_FILES_DISK, lo verifica sullo SHA-256 della busta e lo conferma. Solo dopo lanciaPlatformEventReceived, con$event->files: unReceivedFileDataper file, condisk,path, nome, tipo e SHA-256. Il listener sposta i file nello storage del sistema. Un file che il portale non ha più arriva conisMissing()epathanull: chiedilo di nuovo allo studio che lo ha mandato. - Un file cambiato o sparito dopo
queue()porta la riga infailed, con il percorso inlast_error: il file va mandato con un evento nuovo. Anche un file rifiutato dal portale (422, per esempio il tipo, o 413 oltre i 25 MB) èfailed. La quota piena (429, F11) rimette l'evento in coda dopoRetry-After.
Registro delle transazioni
Ogni incarico inviato si registra sul portale quando nasce, e poi a ogni cambio (regola R9). Ogni PUT è la fotografia completa della transazione, quindi il portale accetta la prima registrazione in qualunque stato, purché coerente con le sue date e con le sue attività. Lo stesso dato copre i due casi:
- la controparte c'è sempre, scelta dal sistema: una persona (un collaboratore, o un consulente trovato con
Platform::searchProfessionals()) o un aderente, dello stesso sistema o di un altro (R3); - gli stati sono
invited,accepted,declined,revokedecompleted. Con un aderente di un altro sistema,acceptededeclinedseguono la sua risposta (R24, vedi «Eventi fra studi»).
La struttura è una sola: tutte le chiavi ci sono sempre, anche quando valgono null, nella testata (responded_at, signed_at, closed_at), nella controparte, nelle attività, nel compenso e nella descrizione. Una chiave mancante è un 422; i DTO del pacchetto le emettono tutte.
Ogni transazione ha un id, un UUID v7 in minuscolo che la outbox genera al primo salvataggio e rimanda identico a ogni revisione (R23): gli eventi fra studi lo citano. $model->platformTransactionId() lo dà già dentro la transazione del database del salvataggio, prima di qualunque risposta del portale.
signed_at è la data della firma dell'incarico, a mano o elettronica, che il sistema registra (R8, R22). È null in invited e declined; facoltativa in accepted, dove null vuol dire «accettato, in attesa di firma», e in revoked; obbligatoria in completed. Sta fra responded_at, che vuole, e closed_at. La firma viene prima del lavoro: senza firma nessuna attività è completed e i minuti lavorati sono null o 0, anche in un'attività revoked; un'attività completed non chiude prima della firma. Una volta mandata, la firma non torna null: il pacchetto non conosce la revisione precedente, il controllo lo fa il portale (R13).
Ogni transazione ha le sue attività (TransactionActivityData), ognuna con il proprio compenso a ore o a corpo, i minuti previsti (sempre obbligatori), lo stato, i minuti lavorati alla chiusura e la descrizione con i soli nomi del catalogo. totalCents() dà il totale come lo calcola il portale.
La testata porta anche title (testo semplice, da 1 a 255 caratteri) e description (Markdown, al massimo 10000 caratteri; l'HTML dentro il testo non è un errore). Sono obbligatori e non vuoti in ogni stato. Regola R6: niente testo libero nelle descrizioni delle attività; title e description sono testi dello studio, senza dati del cliente.
I DTO sono tipizzati e non conoscono i modelli del sistema: è il sistema che mappa i suoi modelli su di loro, di solito in una sola classe.
| DTO | Cosa porta |
|---|---|
TransactionData |
La testata dell'invio, con title e description dello studio, e le sue attività |
CounterpartyData |
CounterpartyData::person($codiceFiscale, $nome, $cognome, $email, $partitaIva, $comune, $provincia), con l'email obbligatoria e gli ultimi tre facoltativi: l'anagrafica al momento dell'invio, identica a ogni revisione (R13). Per un consulente registrato, $consulente->toCounterparty(). Oppure CounterpartyData::member($idAderente), di questo sistema o di un altro. L'email della persona va in minuscolo, come il portale la salva e la restituisce (T6). Sul filo ha sempre le nove chiavi type, member_id, tax_code, first_name, last_name, email, vat_number, municipality, province |
TransactionActivityData |
Un'attività: riferimento, compenso, minuti, stato, chiusura, descrizione |
CompensationData |
CompensationData::hourly($centesimiAllOra) o CompensationData::fixed($centesimi) |
ActivityDescriptionData |
Il processo dell'attività (ProcessData, obbligatorio), il nome dell'attività nel catalogo, o null, e la scadenza Y-m-d |
ProcessData |
Il processo di ogni attività: reference, l'identificativo opaco del sistema (da 1 a 191 caratteri, mai un nome), stabile per ogni istanza del soggetto (processo di linea o di staff, progetto, agenda) e uguale per tutte le sue attività, così lo studio che le riceve le raggruppa nello stesso processo; name, il nome del processo nel catalogo, o null se non ne ha uno |
$diretto = new TransactionData( assignment_reference: $incarico->uuid, principal: $idAderente, counterparty: CounterpartyData::person('RSSMRA80A01H501U', 'Mario', 'Rossi', 'mario.rossi@example.com', municipality: 'Bari', province: 'BA'), typology: 'commercialisti', title: 'Contabilità ordinaria 2026', description: 'Registrazione delle fatture del 2026.', status: TransactionStatus::Invited, sent_at: CarbonImmutable::parse($proposta->sent_at), activities: [new TransactionActivityData( reference: (string) $riga->id, compensation: CompensationData::hourly(4500), estimated_minutes: 120, status: TransactionActivityStatus::Open, description: new ActivityDescriptionData(new ProcessData((string) $processo->id, 'Contabilità ordinaria'), 'Registrazione fatture', '2026-11-30'), )], );
I minuti lavorati dipendono dal tipo della controparte: con una persona sono obbligatori in un'attività completed, con un aderente sono sempre null (R5).
La parte ferma all'invio (controparte, tipologia, testi, prezzi) non cambia fra una revisione e l'altra (R13). Il modello che registra la transazione la costruisce da quello che ha fotografato all'invio, non dalle anagrafiche di oggi; un cambio dell'anagrafica si dichiara a parte (vedi «Anagrafica dei professionisti»). TransactionData::from() legge anche il corpo del portale, con la descrizione nella forma dello schema ({"process": {"reference": "…", "name": "…"}, "activity": {"name": "…"}}).
Il modello che corrisponde all'invio lo dichiara con un'interfaccia, e il pacchetto fa il resto:
use ITuoiProfessionistiDigitali\Connector\Concerns\RecordsOnPlatform; use ITuoiProfessionistiDigitali\Connector\Contracts\RecordsPlatformTransaction; use ITuoiProfessionistiDigitali\Connector\Data\TransactionData; final class ExternalAssignmentProposal extends Model implements RecordsPlatformTransaction { use RecordsOnPlatform; public function platformTransactionReference(): ?string { return $this->uuid; // null per una bozza mai inviata } public function toPlatformTransaction(): TransactionData { return resolve(ProposalMapper::class)->toTransaction($this); // la mappatura del sistema } }
I modelli che cambiano la transazione senza esserlo, come le sue righe o l'incarico che la contiene, la registrano di nuovo a ogni salvataggio ed eliminazione:
use ITuoiProfessionistiDigitali\Connector\Concerns\RecordsAffectedOnPlatform; use ITuoiProfessionistiDigitali\Connector\Contracts\AffectsPlatformTransactions; final class ExternalAssignmentActivity extends Model implements AffectsPlatformTransactions { use RecordsAffectedOnPlatform; public function affectedPlatformTransactions(): iterable { return ExternalAssignmentProposal::query()->whereKey($this->proposal_id)->get(); } }
- Un trait usato senza la sua interfaccia lancia una
LogicException. - Ogni salvataggio scrive la versione attuale in una outbox (
php artisan migratecrea la tabellaplatform_transaction_outbox), nella stessa transazione del database, con una revisione nuova solo se qualcosa è cambiato. Le date si confrontano in UTC, come le restituisce il portale: lo stesso istante scritto in un altro fuso non è un cambio. Una bozza, con il riferimento anull, non ci arriva. L'outbox tiene solo l'ultima versione: se l'invio diinvitednon è riuscito e intanto l'incarico è stato accettato, al portale arriva come prima registrazione la versioneaccepted, ed è accettata. - Un job in coda la manda con
PUT /transactions/{reference}, con il suoid. Riprova sugli errori di rete e su un sistema non ancora attivo; si ferma, segnando l'errore, su una violazione del contratto o un conflitto. Vale anche quando la violazione la trova il pacchetto prima della richiesta, per esempio su una versione salvata prima che cambiasse una regola: la riga finiscefailedeplatform:send-outboxnon la riprende. $model->isRecordedOnPlatform()dice se il portale ha confermato la versione attuale. La registrazione segue il lavoro, non lo blocca.- Ogni cinque minuti il comando schedulato
platform:send-outboxrimanda le versioni rimaste indietro, per esempio mentre il sistema era in attesa di verifica. - Una transazione non si cancella: un invio ritirato si salva come
revoked, con tutte le attività chiuse. - Lo storico si carica con
Platform::recordTransactions(), fino a 500 per chiamata, con le stesse regole della PUT (R10). Il pacchetto dà a ogni transazione il suoidprima della richiesta, e una transazione nuova entra nella outbox come da mandare: se il batch non risponde,platform:send-outboxla manda con lo stesso id; una che il portale rifiuta finiscefailedcon il suo errore. Ogni transazione che il portale ha (created,updated,unchanged) entra nella outbox come già confermata, così la outbox conosce ogni persona incaricata. Platform::transactions()legge le transazioni in cui il committente o la controparte è un aderente del sistema, con i filtristatuseupdatedSince.Platform::transaction($id)ne legge una per id, anche dalla parte della controparte; se il sistema non la vede,TransactionNotFoundException(il portale risponde con un elenco vuoto, mai 404).Platform::recordTransaction($reference, $id, $transazione, $revisione)fa la sola chiamata, senza outbox: l'iddev'essere un UUID in minuscolo, lo stesso a ogni revisione.- Nei test del sistema
PlatformOutbox::assertRecorded($model)verifica che la versione attuale sia nella outbox,PlatformOutbox::assertNotRecorded($model)che una bozza non ci sia.
Consulenti registrati
I consulenti che si registrano sul portale e danno il consenso a essere trovati dagli studi si cercano senza contatti, e si leggono uno alla volta con i dati per affidare un incarico (N1–N5). L'incarico è poi come ogni incarico a persona: il sistema lo registra con la controparte persona e lo porta avanti da sé.
$pagina = Platform::searchProfessionals(typology: 'commercialisti', province: 'BA', search: 'rossi'); // ProfessionalPage foreach ($pagina->professionals as $consulente) { /* ListedProfessionalData: id, nome, cognome, comune, provincia, tipologie, tax_code_verified */ } $consulente = Platform::professional($id); // ProfessionalData, con codice fiscale, email, cellulare e partita IVA $controparte = $consulente->toCounterparty();
- L'elenco non ha contatti: codice fiscale, email, cellulare e partita IVA escono solo dal dettaglio (N3).
- Il portale registra ogni lettura del dettaglio (N5): leggilo quando lo studio sta per affidare l'incarico, non per riempire un elenco. I contatti sono dati personali: niente cache, niente log, niente copie fuori dall'anagrafica che il sistema crea per l'incarico.
- Un consulente che non è più consultabile, anche perché ha revocato il consenso, è
ProfessionalNotFoundException: il portale non dice perché. tax_code_verified: falsevuol dire un codice fiscale dichiarato, con il solo controllo formale.- L'anagrafica di un consulente registrato è bloccata sul portale (R20): l'incarico la fotografa così com'è.
Anagrafica dei professionisti
Il professionista è uno per codice fiscale in tutto il portale. Nasce dalla prima registrazione di un incarico a persona, con l'anagrafica dell'invio. Quando poi l'anagrafica cambia nel sistema, il sistema la dichiara con PUT /professionals/{tax_code} (regola R18), datata dal momento del cambio: vince la dichiarazione più recente, non l'ultima arrivata (R19). Le transazioni non cambiano: ognuna conserva l'anagrafica del suo invio (R13).
use ITuoiProfessionistiDigitali\Connector\Data\ProfessionalRecordData; use ITuoiProfessionistiDigitali\Connector\Outbox\ProfessionalOutbox; resolve(ProfessionalOutbox::class)->declare($collaboratore->codice_fiscale, new ProfessionalRecordData( first_name: 'Mario', last_name: 'Rossi', declared_at: CarbonImmutable::parse($collaboratore->updated_at), email: 'mario.rossi@example.com', vat_number: '01234567897', municipality: 'Lecce', province: 'LE', ));
- L'anagrafica è sempre completa: un campo facoltativo a
nulltoglie il dato sul portale. Nome, cognome ed email sono obbligatori, come nella controparte persona, e l'email va in minuscolo (T6); facoltativi restano partita IVA, comune e provincia.declared_atnon può essere oltre 5 minuti nel futuro. - Una persona mai incaricata non arriva al portale: senza una riga della outbox delle transazioni con quel codice fiscale, in qualunque stato,
declare()non tiene né manda nulla, non ne controlla nemmeno i dati e restituiscenull. Il sistema può chiamarla a ogni cambio dell'anagrafica, senza condizioni sue, anche per un referente senza email, conemail: ''. Per una persona incaricata, invece, dati non validi sono unaValidationException. Il codice fiscale si confronta in maiuscolo. - L'outbox delle dichiarazioni (tabella
platform_professional_outbox) tiene l'ultima per codice fiscale: ignora una dichiarazione più vecchia e una con la stessa anagrafica. La manda in coda dopo il commit. - Il portale risponde 404 finché non ha una transazione a persona del sistema con quel codice fiscale. Se l'outbox delle transazioni ne ha ancora di mai confermate, la dichiarazione aspetta e riparte da sola quando una è confermata; altrimenti si chiude come
discarded, con un log, e non è un errore (per esempio un codice fiscale corretto dopo l'invio). - Un 204 chiude la dichiarazione anche quando il portale tiene la sua anagrafica, più recente o bloccata (R20): il sistema non lo sa e non deve saperlo.
Platform::declareProfessional($codiceFiscale, $anagrafica)fa la sola chiamata, senza outbox: un 404 diventaProfessionalNotAssignedException.- Una dichiarazione che viola il contratto, anche se la trova il pacchetto prima della richiesta, finisce
failedcon l'errore e non si ripete. Vale anche per una dichiarazione salvata prima che cambiasse una regola. platform:send-outboxrimanda anche le dichiarazioni in attesa. Nei testPlatformOutbox::assertDeclared($codiceFiscale, $anagrafica)verifica che sia l'ultima dichiarazione nella outbox.
Errori
Ogni rifiuto del portale è una PlatformRequestException, con ->status, ->errors (chiavi puntate come payload.challenge), ->reason per i 403 (SystemStatus) e ->existingEvent per i 409. Lo è anche quando il pacchetto rifiuta prima di mandare per lo schema del payload o per un limite, come le 500 transazioni. Un DTO che viola il contratto è invece una ValidationException di Laravel, con le stesse chiavi puntate in ->errors(), prima di qualunque richiesta. Hanno un'eccezione propria i 404 di declareProfessional() (ProfessionalNotAssignedException), di member() (MemberNotFoundException) e di professional() (ProfessionalNotFoundException), e l'elenco vuoto di transaction() (TransactionNotFoundException). Il limite dei 1000 aderenti è invece una ValidationException su members. Anche oltre le 500 attività di una transazione la ValidationException è sul solo activities, senza gli errori delle singole attività. Senza credenziali il client lancia PlatformNotConfiguredException.
Un 429 non è un errore della richiesta (T7). Il client aspetta da solo un Retry-After fino a 10 secondi e ripete la richiesta; uno più lungo, o senza un numero di secondi, è una PlatformRequestException con isThrottled() e i secondi in ->retryAfter (60 se l'header manca). I job delle outbox rimettono la riga in coda dopo quel tempo, senza contarlo come un errore.
Sistemi non Laravel
Il contratto è HTTP; il pacchetto è una comodità. La firma di un webhook si verifica così:
firma = hex( HMAC-SHA256( PLATFORM_SIGNING_SECRET, X-Platform-Timestamp + "." + corpo grezzo ) )
X-Platform-Signature contiene una o due firme v1=<hex> separate da virgola: basta che una coincida. Va rifiutato con 401 un timestamp distante più di 5 minuti.
Versioni
La major del pacchetto cambia a ogni rottura del contratto: un campo che sparisce, uno che diventa obbligatorio, una regola più stretta. Le versioni degli schemi di un tipo di evento si aggiungono e non cambiano mai: quelle fissate in resources/schemas sono confrontate con il portale da un contract test.
L'API pubblica è quella che il README descrive. Quello che porta @internal lo usa solo il pacchetto e può cambiare in una minor, senza avviso: un test lo tiene allineato.
Sviluppo
composer lint # Pint e PHPStan al livello max composer test # Pest composer test:coverage # copertura al 100%, con PCOV o Xdebug