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-16 07:16:49 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, 'email' => 'segreteria@studiorossi.example', ]), ]); $members[0]->id; // l'id del portale: il sender dei tuoi eventi
L'elenco è completo: gli aderenti che mancano diventano inattivi.
email è obbligatoria: è l'email coworking della struttura, l'indirizzo per le notifiche della sua area sul portale (M14). È unica fra gli aderenti di tutto il portale, di qualunque sistema (M15): una duplicata è un 422 su members.N.email. Il pacchetto controlla solo che due aderenti della stessa richiesta non abbiano la stessa email, perché non conosce quelli degli altri sistemi. Le email non distinguono maiuscole e minuscole (T6): MemberData porta l'email in minuscolo, come il portale la salva e la restituisce, e due email della stessa richiesta che differiscono solo per le maiuscole sono la stessa. Sul portale è anche l'email con cui lo studio entra nella sua area; nelle chiamate del pacchetto, invece, un aderente si riconosce da external_ref o dal suo id, mai dall'email. La risposta di syncMembers() la riporta per ogni aderente. searchMembers() invece non la mostra mai, come il codice fiscale (M11).
I rifiuti di syncMembers() per l'email hanno un'eccezione propria (M15):
use ITuoiProfessionistiDigitali\Connector\Exceptions\ConcurrentMemberSyncException; use ITuoiProfessionistiDigitali\Connector\Exceptions\MemberEmailsRejectedException; try { $members = Platform::syncMembers($all); } catch (ConcurrentMemberSyncException) { // 422 su `members`: un'altra richiesta ha usato la stessa email nello stesso momento. // Il portale non ha applicato nulla (M6): rimanda lo stesso elenco. } catch (MemberEmailsRejectedException $exception) { // 422 solo su `members.N.email`: per external_ref, i messaggi del portale. // Non si ritenta: l'email va risolta con lo studio. $exception->messages; // ['struttura-1' => ['…'], …] }
Un 422 con altri errori, anche insieme alle email, resta una PlatformRequestException; nelle due eccezioni quella del portale è in getPrevious().
$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);
Accesso all'area dello studio
Il sistema porta i suoi utenti nell'area riservata di un suo aderente sul portale con un link firmato (regole U1–U4). L'accesso è per studio e non per persona: la richiesta non ha corpo, e chi ha cliccato lo registra il sistema.
use ITuoiProfessionistiDigitali\Connector\Exceptions\MemberNotAccessibleException; try { $link = Platform::memberAccessLink($aderente->platform_id); } catch (MemberNotAccessibleException) { abort(404); } return redirect()->away($link->url);
- Il link vale una volta sola e scade dopo 5 minuti (
$link->expires_at). Ogni chiamata ne crea uno nuovo. - Il link è un segreto (U4): usalo subito con un redirect del browser, senza conservarlo, metterlo in cache o scriverlo in un log. Se un log ha bisogno di un riferimento, usa solo
expires_at. Il pacchetto non lo mette in cache e non lo scrive nei log. Se il sistema registra le risposte del client HTTP, per esempio con Telescope o con un listener diResponseReceived, deve escludere questa rotta. - Un aderente di un altro sistema, inesistente o inattivo dà
MemberNotAccessibleException: il portale non dice quale dei tre. Un aderente conlisted: falseha il suo link come gli altri. Un sistema non attivo riceve 403, oltre 30 richieste al minuto 429, entrambi comePlatformRequestException.
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 } }
Gli eventi che genera il portale, quelli delle candidature e della scelta (transaction.*), arrivano allo stesso modo, con sender a null (regola L6): $envelope->isFromPlatform() li riconosce. Nel catalogo hanno sent_by: platform (EventTypeData::$sent_by), e Platform::send() li rifiuta prima di mandarli (E5). Una busta senza mittente di un tipo, o di una versione, che il pacchetto non conosce 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, o un evento del portale con il payload rotto, ha ancora 400.
Eventi fra studi
Scelto uno studio, il portale manda transaction.assignment_received al sistema dello studio (AssignmentReceivedData): la transazione accettata, con l'aderente come controparte, e il committente (PrincipalData), che si fa conoscere solo adesso. La transazione è del committente: il sistema dello studio non la registra.
Da lì i due studi si parlano con tre tipi del catalogo, sempre con correlation_id uguale all'id della transazione e la sua tipologia (E13):
| Tipo | Payload | Chi lo manda |
|---|---|---|
coworking.contract_details |
ContractDetailsData: lo studio (PartyData, con la sede in AddressData) e chi firma (SignatoryData) |
Tutti e due, subito dopo la scelta |
coworking.activities_assigned |
ActivitiesAssignedData: i processi (AssignedProcessData) col cliente finale (FinalClientData), le attività (AssignedActivityData) e le guide (GuideData) |
Il committente, dopo la firma dei documenti del coworking |
coworking.activity_status |
ActivityStatusData: un'attività completed o reopened (ActivityStatusChange), con l'istante |
Lo studio scelto |
use ITuoiProfessionistiDigitali\Connector\Data\ActivityStatusData; use ITuoiProfessionistiDigitali\Connector\Outbox\EventOutbox; $busta = ActivityStatusData::validateAndCreate([ 'transaction' => ['id' => $transazione->id], 'activity' => $riga->reference, 'status' => 'completed', 'occurred_at' => now()->format(Contract::DATE_FORMAT), ])->toEnvelope(sender: $mioAderente, recipient: $committente->id, typology: $transazione->typology); resolve(EventOutbox::class)->queue($busta);
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:
ContractDetailsData::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 (
ReceivePlatformFiles, 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:
- incarico affidato a una controparte che il sistema conosce già:
counterpartypresente,audienceuguale al suo tipo (personomember),expires_atanull, statiinvited,accepted,declined,revoked,completed; - incarico pubblicato sul portale:
counterpartyanull, statipublishedewithdrawn, conexpires_ateaudience(person,memberoany).
La struttura è una sola: tutte le chiavi ci sono sempre, anche quando valgono null, nella testata (counterparty, expires_at, 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. audience non cambia dopo la prima registrazione.
signed_at è la data della firma dell'incarico, a mano o elettronica, che il sistema registra (R8, R22). È null in published, withdrawn, 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; un incarico pubblicato li mostra sul portale.
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). Oppure CounterpartyData::member($idAderente). 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, audience: Audience::Person, 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'), )], );
Un incarico pubblicato ha counterparty: null, status: TransactionStatus::Published, expires_at e audience: Audience::Any (o Person, Member). I minuti lavorati dipendono dal tipo della controparte: con una persona sono obbligatori in un'attività completed, con un aderente o senza controparte sono sempre null (R5).
La parte ferma all'invio (audience, 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}. 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). 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 il registro del sistema, con il filtroaudience. - Nei test del sistema
PlatformOutbox::assertRecorded($model)verifica che la versione attuale sia nella outbox,PlatformOutbox::assertNotRecorded($model)che una bozza non ci sia.
Candidature e scelta della controparte
Un incarico pubblicato trova la sua controparte sul portale. I professionisti registrati, agli incarichi person o any, e gli studi aderenti di un altro sistema, agli incarichi member o any (L10), si candidano accettandone le condizioni, e il committente sceglie: la scelta porta l'incarico da published ad accepted (regole L2–L8). Il sistema del committente riceve quattro eventi del portale:
| Tipo | Payload | Quando |
|---|---|---|
transaction.application_received |
ApplicationReceivedData |
Una candidatura nuova, pending |
transaction.application_withdrawn |
ApplicationWithdrawnData |
Una candidatura ritirata, con closed_at |
transaction.counterparty_selected |
CounterpartySelectedData |
La scelta, fatta dal sistema con l'API o dallo studio dalla sua area |
transaction.withdrawn |
TransactionWithdrawnData |
Il ritiro automatico di un incarico rimasto senza scelta dopo la scadenza (L9) |
use ITuoiProfessionistiDigitali\Connector\Contract; use ITuoiProfessionistiDigitali\Connector\Data\CounterpartySelectedData; if ($envelope->type === Contract::COUNTERPARTY_SELECTED) { $scelta = CounterpartySelectedData::from($envelope->payload); $scelta->transaction; // RecordedTransactionData, accepted, con la controparte persona o aderente $scelta->application; // ApplicationData, selected $scelta->contact?->mobile; // il cellulare in E.164, fuori dalla controparte; null per uno studio }
- Una candidatura (
ApplicationData) porta lo stato,applied_at, il consenso alle condizioni (terms_accepted_at), la revisione accettata (accepted_revision) e il candidato (ApplicantData). Un professionista ha nome, cognome, comune, provincia etax_code_verified; uno studio ($candidato->isMember()) haname, comune e provincia, con i campi della persona anull, e il suo id arriva solo con la scelta, nella controparte. Codice fiscale, email e partita IVA arrivano solo con la scelta, nella controparte, e il cellulare solo nell'evento della scelta (L7). Platform::applications($reference, ApplicationStatus::Pending, perPage: 25, cursor: null)legge le candidature di un incarico, una pagina alla volta (ApplicationPage).Platform::selectApplication($reference, $idCandidatura)sceglie e restituisce la transazioneaccepted. Scegliere di nuovo la stessa candidatura risponde con la transazione com'è. I rifiuti hanno un'eccezione propria:TransactionNotFoundException(404, lareferencenon è del sistema),ApplicationNotSelectableException(422 suapplication: candidatura sconosciuta, di un'altra transazione o non piùpending) eTransactionNotPublishedException(422 sustatus: incarico ritirato o già accettato). Non si ritentano.transaction.counterparty_selectedarriva sempre, anche quando la scelta l'ha fatta il sistema: è la strada unica per creare quello che serve, come l'anagrafica della persona scelta. Il listener è idempotente sull'event_ide sullareference.- Con la scelta il portale registra la transazione alla sua revisione più 1. Il pacchetto la scrive nella outbox come confermata, sia dalla risposta di
selectApplication()sia dall'evento, prima dei listener. Se la outbox ha già una revisione più alta, resta com'è. Il prossimo salvataggio del modello parte quindi dalla revisione successiva, senza conflitti; una versione rimasta in attesa alla stessa revisione, come un ritiro, è sostituita dalla scelta, che il portale non disfa. - Scelto uno studio, la controparte è
{"type": "member", "member_id": …}con i campi della persona anull, anche se l'aderente è di un altro sistema, econtactènull: i due studi si scambiano i dati concoworking.contract_details. - Da lì il modello continua come un incarico affidato:
accepted, la controparte identica a quella ricevuta ($scelta->transaction->counterparty),responded_atdella scelta,signed_atalla firma.audienceedexpires_atrestano quelli della pubblicazione (R16): un incarico pubblicato peranyrestaany.RecordedTransactionData::toTransaction()dà la transazione come il sistema la rimanda.
Candidarsi come studio
Un aderente del sistema si candida agli incarichi pubblicati per gli studi da committenti di altri sistemi (L1, L10). Il committente resta anonimo fino alla scelta: dell'inserzione esce solo la sede (L7).
use ITuoiProfessionistiDigitali\Connector\Facades\Platform; $pagina = Platform::listings('commercialisti', perPage: 25); // ListingPage, dalla più recente $inserzione = Platform::listing($id); // ListingData // Prima raccogli il consenso esplicito di chi decide per lo studio: candidarsi accetta le condizioni $candidatura = Platform::apply($inserzione->id, $idAderente); // MemberApplicationData, pending Platform::withdrawApplication($inserzione->id, $candidatura->id); $mie = Platform::memberApplications($idAderente, ApplicationStatus::Pending); // MemberApplicationPage
ListingDataha l'id della transazione, la tipologia,audience, titolo e testo, la sede del committente (ListingPrincipalData),sent_at,expires_at, i totali e le attività (ListingActivityData) con compenso, minuti previsti, totale e descrizione col processo.revisionè quella di cui la candidatura accetta le condizioni.apply()manda sempreterms_accepted: true: chiamalo solo dopo il consenso. La stessa candidatura ripetuta risponde con quella esistente.- Le eccezioni non si ritentano:
ListingNotFoundException(404, inserzione non più visibile),ApplicationRefusedException(422 sumember: aderente non attivo, senza la tipologia o candidatura già ritirata, con i messaggi in->messages),ApplicationNotFoundException(404 sul ritiro, candidatura di un altro sistema) eApplicationNotWithdrawableException(422 sul ritiro, candidatura già scelta o rifiutata). memberApplications()dice come sono andate le candidature:declinedè un'altra scelta o l'incarico ritirato;selectedarriva insieme atransaction.assignment_received, e da lì la transazione esce anche inPlatform::transactions().
Ritiro automatico
Un incarico ancora published senza una scelta lo ritira il portale, alcuni giorni dopo expires_at: 30, ma li configura l'operatore del portale, quindi il sistema non ci conta come su un valore fisso (regola L9). Il portale porta la transazione in withdrawn, con closed_at all'istante del ritiro e la revisione registrata più 1, chiude le candidature pending come declined e manda transaction.withdrawn al sistema del committente. Il ritiro fatto dal sistema con un PUT non genera eventi.
use ITuoiProfessionistiDigitali\Connector\Contract; use ITuoiProfessionistiDigitali\Connector\Data\TransactionWithdrawnData; use ITuoiProfessionistiDigitali\Connector\Enums\WithdrawalReason; if ($envelope->type === Contract::TRANSACTION_WITHDRAWN && $envelope->isFromPlatform()) { $ritiro = TransactionWithdrawnData::from($envelope->payload); $ritiro->transaction; // RecordedTransactionData, withdrawn, con closed_at $ritiro->reason; // WithdrawalReason::Expired }
- Come per la scelta, il pacchetto scrive la transazione ritirata nella outbox come confermata, prima dei listener, salvo una revisione più alta. Un ritiro del sistema rimasto in attesa, o rifiutato con un 409 alla stessa revisione perché il portale aveva già ritirato, è sostituito da quello del portale: la riga
faileddi questa corsa si sistema da sola con l'evento. - Il listener è in coda e idempotente sull'
event_ide sullareference. Se l'invio nel sistema è ancora pubblicato, lo ritira come un ritiro a mano, con ilclosed_atdell'evento, così la outbox non manda una revisione nuova. Se è già ritirato, o non c'è più, non fa nulla. - Ritiro e scelta non si sovrappongono: per una
referencearriva al massimo uno dei due eventi. Una scelta dopo il ritiro risponde conTransactionNotPublishedException.
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, come due email uguali nella stessa syncMembers(). Hanno un'eccezione propria due 404, ProfessionalNotAssignedException per l'anagrafica e MemberNotAccessibleException per il link d'accesso, e due 422 di syncMembers(), ConcurrentMemberSyncException e MemberEmailsRejectedException. Anche le candidature e la scelta hanno le loro: TransactionNotFoundException per il 404, ApplicationNotSelectableException e TransactionNotPublishedException per i 422 della scelta. Il limite dei 1000 aderenti è invece una ValidationException su members, che non si confonde con la corsa. 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