diff --git a/.api-sync/known-divergences.json b/.api-sync/known-divergences.json new file mode 100644 index 0000000..5a22f66 --- /dev/null +++ b/.api-sync/known-divergences.json @@ -0,0 +1,67 @@ +{ + "$schema": "Recorded, reasoned, owned divergences that state reconciliation must not silently paper over. Two kinds: `enumValues` is a spec enum member whose value does not match any SDK case value even though the SDK models that enum (usually because an SDK case value has a typo/format bug) -- the fix is to CORRECT the existing case, not to add a near-duplicate new one, so scripts/api-sync.php treats a listed (enum, specValue) pair as satisfied rather than pending drift. `fields` covers two situations, both keyed by {schema, field}: (a) a spec property backed by a constrained enum but modeled as a plain untyped field in the SDK, so there is no case-completeness gap to begin with; (b) a genuine type-representation mismatch between the spec's declared type and the SDK's declared PHP property type on an already-modeled field, recorded here rather than silently accepted or blindly auto-fixed, since correcting it is a deliberate code change a human must make. Distinct from .api-sync/unmodeled.json, which is for properties absent from the SDK outright.", + "enumValues": [ + { + "enum": "BankAccountType", + "specValue": "saving", + "sdkCase": "SAVINGS", + "sdkValue": "savings", + "reason": "LIVE DEFECT (not fixed in this PR): the API's wire value is 'saving' (singular); this SDK's case value is 'savings' (plural), so a PHP backed enum currently serializes the wrong string and a real 'saving' account_type response would fail to parse via BankAccountType::from(). To be fixed in its own deliberate PR that corrects the case value directly instead of adding a second near-duplicate case.", + "owner": "eric@blindpay.com" + }, + { + "enum": "BankAccountType", + "specValue": null, + "sdkCase": "TED", + "sdkValue": "ted", + "reason": "SDK-only case with no counterpart in the spec's account_type enum (which only has checking/saving). Pre-existing, unrelated to account_type modeling; not touched here.", + "owner": "eric@blindpay.com" + }, + { + "enum": "EstimatedAnnualRevenue", + "specValue": "250000000_plus", + "sdkCase": "RANGE_2500000000_PLUS", + "sdkValue": "2500000000_plus", + "reason": "The API's top revenue bucket is '250000000_plus' (250 million); this SDK's case value has an extra digit, '2500000000_plus' (2.5 billion), so a real API response for the top bucket would fail to parse via EstimatedAnnualRevenue::from(). Same shape of bug as BankAccountType.saving/savings -- to be fixed in its own deliberate PR that corrects the case value, not by adding a second near-duplicate case.", + "owner": "eric@blindpay.com" + } + ], + "fields": [ + { + "schema": "CustomerOut", + "field": "kyc_status", + "reason": "Spec constrains kyc_status to an 8-value enum (verifying/approved/rejected/deprecated/pending_review/awaiting_contract/compliance_request/approved_rfi). This SDK models it as a plain `string` on BaseCustomer, not a backed enum, so every spec value already parses without error -- there is no case-completeness gap in code today. Recorded for visibility only.", + "owner": "eric@blindpay.com" + }, + { + "schema": "QuoteOut", + "field": "expires_at", + "reason": "TYPE MISMATCH, pre-existing and identical between the last two spec snapshots (not new drift): spec declares expires_at as `number|null` (a float), CreateQuoteResponse declares `public int $expiresAt` with no cast in fromArray. In practice this is a Unix timestamp (spec example: 1712958191, always a whole number), so json_decode already hands back a PHP int and the mismatch has not surfaced -- but a genuinely fractional or null value from the API would throw a TypeError under strict_types. Not fixed here: changing the property type is a deliberate decision, not a mechanical field add.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreatePayinQuoteOut", + "field": "expires_at", + "reason": "Same pre-existing mismatch as QuoteOut.expires_at, on the payin-quote equivalent (CreatePayinQuoteResponse). Here the field IS cast, `expiresAt: (int) $data['expires_at']`, so a fractional value would silently truncate rather than throw -- a real risk in disguise if the API ever returns one.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayinOut", + "field": "billing_fee_amount", + "reason": "LIVE DEFECT, VERIFIED BY RUNNING CODE (not fixed in this PR): spec declares billing_fee_amount as `number|null` (a fee in cents), Payin declares `public ?string $billingFeeAmount` with no cast in fromArray. Calling Payin::fromArray() with any present, non-null billing_fee_amount throws `TypeError: BlindPay\\SDK\\Resources\\Payins\\Payin::__construct(): Argument #35 ($billingFeeAmount) must be of type ?string, int given` -- reproduced directly, not theoretical. Every payin response where this fee is populated (end of month) fails to parse. Not fixed here: choosing the correct type (float? string with an explicit cast?) is a deliberate decision, pre-existing and identical between the last two spec snapshots (not new drift).", + "owner": "eric@blindpay.com" + }, + { + "schema": "PaginationMetadata", + "field": "next_page", + "reason": "LIVE DEFECT, VERIFIED BY RUNNING CODE (not fixed in this PR): spec declares next_page as a nullable STRING cursor (`type: [\"string\",\"null\"]`, example `\"pi_123\"`, the ID of the first item in the next page), but PaginationMetadata declares `public int $nextPage` with no cast. Calling PaginationMetadata::fromArray() with any present, non-null next_page throws `TypeError: BlindPay\\SDK\\Types\\PaginationMetadata::__construct(): Argument #2 ($nextPage) must be of type int, string given` -- reproduced directly, not theoretical. PaginationMetadata backs the customers/payouts/payins/transfers list responses, so ANY paginated list response that actually has a next page fails to parse through this SDK. Not fixed here: the property type needs to become `?string`, a deliberate code change with its own PR.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PaginationMetadata", + "field": "prev_page", + "reason": "Same live defect as PaginationMetadata.next_page, on the previous-page cursor (`public int $prevPage`, spec declares a nullable string). Not independently re-verified by execution but identical shape and identical code path.", + "owner": "eric@blindpay.com" + } + ] +} diff --git a/.api-sync/spec-map.json b/.api-sync/spec-map.json new file mode 100644 index 0000000..701e1a7 --- /dev/null +++ b/.api-sync/spec-map.json @@ -0,0 +1,231 @@ +{ + "$schema": "Curated mapping from OpenAPI spec constructs to SDK symbols, used by scripts/api-sync.php for state reconciliation. `enums` pairs a spec enum-bearing property (or the webhooks map) with the SDK enum symbol that must contain every spec member. `types` pairs a spec schema (optionally a nested inline `path` within it, for shapes with no dedicated component schema) with the SDK class(es) that read/write it; `discriminator` is informational, documenting how a single flattened spec schema fans out into multiple SDK classes. `ignore.schemas` lists schemas this SDK deliberately does not model, with a reason. Verified by hand against src/ on 2026-08-03.", + "enums": [ + { + "spec": { "kind": "webhookTopics" }, + "sdk": { "file": "src/Resources/Webhooks/Webhooks.php", "class": "WebhookEvents" }, + "note": "contract-check.php Direction B hard-fails if a spec webhooks map key has no matching case value here." + }, + { + "spec": { "schema": "BankAccountOut", "property": "account_class" }, + "sdk": { "file": "src/Types/AccountClass.php", "class": "AccountClass" } + }, + { + "spec": { "schema": "CustomerOut", "property": "account_purpose" }, + "sdk": { "file": "src/Types/AccountPurpose.php", "class": "AccountPurpose" } + }, + { + "spec": { "schema": "CustomerOut", "property": "aml_status" }, + "sdk": { "file": "src/Types/AmlStatus.php", "class": "AmlStatus" } + }, + { + "spec": { "schema": "BankAccountOut", "property": "account_type" }, + "sdk": { "file": "src/Types/BankAccountType.php", "class": "BankAccountType" }, + "note": "KNOWN DIVERGENCE, see .api-sync/known-divergences.json: spec value 'saving' vs SDK case value 'savings'. Do not blind-add a new case for 'saving'; that would create a duplicate near-identical case instead of fixing the real one. Kept mapped on purpose so the divergence stays visible." + }, + { + "spec": { "schema": "VirtualAccountOut", "property": "banking_partner" }, + "sdk": { "file": "src/Types/BankingPartner.php", "class": "BankingPartner" } + }, + { + "spec": { "schema": "CustomerOut", "property": "business_industry" }, + "sdk": { "file": "src/Types/BusinessIndustry.php", "class": "BusinessIndustry" } + }, + { + "spec": { "schema": "CustomerOut", "property": "business_type" }, + "sdk": { "file": "src/Types/BusinessType.php", "class": "BusinessType" } + }, + { + "spec": { "schema": "CustomerOut", "property": "country" }, + "sdk": { "file": "src/Types/Country.php", "class": "Country" } + }, + { + "spec": { "operation": "POST /v1/instances/{instance_id}/payin-quotes/fx", "property": "from" }, + "sdk": { "file": "src/Types/Currency.php", "class": "Currency" }, + "note": "inline (non-$ref) request body; the widest currency enum in the spec, superset of the quotes/fx operation's from/to." + }, + { + "spec": { "schema": "QuoteIn", "property": "currency_type" }, + "sdk": { "file": "src/Types/CurrencyType.php", "class": "CurrencyType" } + }, + { + "spec": { "schema": "CustomerOut", "property": "estimated_annual_revenue" }, + "sdk": { "file": "src/Types/EstimatedAnnualRevenue.php", "class": "EstimatedAnnualRevenue" }, + "note": "KNOWN DIVERGENCE, see .api-sync/known-divergences.json: spec value '250000000_plus' vs SDK case value '2500000000_plus' (extra digit). Kept mapped on purpose so the divergence stays visible." + }, + { + "spec": { "schema": "WalletOut", "property": "network" }, + "sdk": { "file": "src/Types/Network.php", "class": "Network" } + }, + { + "spec": { "schema": "PayinOut", "property": "payment_method" }, + "sdk": { "file": "src/Types/PaymentMethod.php", "class": "PaymentMethod" } + }, + { + "spec": { "schema": "BankAccountOut", "property": "type" }, + "sdk": { "file": "src/Types/Rail.php", "class": "Rail" } + }, + { + "spec": { "schema": "BankAccountOut", "property": "recipient_relationship" }, + "sdk": { "file": "src/Types/RecipientRelationship.php", "class": "RecipientRelationship" } + }, + { + "spec": { "schema": "CreateVirtualAccountIn", "property": "sole_proprietor_doc_type" }, + "sdk": { "file": "src/Types/SoleProprietorDocType.php", "class": "SoleProprietorDocType" } + }, + { + "spec": { "schema": "CustomerOut", "property": "source_of_wealth" }, + "sdk": { "file": "src/Types/SourceOfWealth.php", "class": "SourceOfWealth" } + }, + { + "spec": { "schema": "VirtualAccountOut", "property": "token" }, + "sdk": { "file": "src/Types/StablecoinToken.php", "class": "StablecoinToken" } + }, + { + "spec": { "schema": "PayoutOut", "property": "transaction_document_type" }, + "sdk": { "file": "src/Types/TransactionDocumentType.php", "class": "TransactionDocumentType" } + }, + { + "spec": { "schema": "PayoutOut", "property": "status" }, + "sdk": { "file": "src/Types/TransactionStatus.php", "class": "TransactionStatus" }, + "note": "union of PayoutOut/PayinOut/TransferOut.status; SDK's PENDING_REVIEW case has no counterpart in any of the three today, which is fine (SDK may accept a superset)." + } + ], + "typesNote": "Class-per-file below always refers to src/Resources/**/*.php unless the file starts with src/Types/.", + "types": [ + { "spec": "BankAccountOut", "sdk": [ { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "BankAccountListItem" } ] }, + { + "spec": "CreateBankAccountIn", + "discriminator": { "property": "type", "values": ["pix", "transfers_bitso", "spei_bitso", "ach_cop_bitso", "ach", "wire", "international_swift", "rtp", "pix_safe", "sepa"] }, + "sdk": [ + { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "CreatePixInput" }, + { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "CreateArgentinaTransfersInput" }, + { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "CreateSpeiInput" }, + { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "CreateColombiaAchInput" }, + { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "CreateAchInput" }, + { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "CreateWireInput" }, + { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "CreateInternationalSwiftInput" }, + { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "CreateRtpInput" }, + { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "CreatePixSafeInput" }, + { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "CreateSepaInput" } + ], + "note": "spec's `type` enum also has `ted`, which this SDK does not implement (no createTed method) -- a known, pre-existing coverage gap, not modeled here, tracked as a Phase C item." + }, + { + "spec": "CreateCustomerIn", + "discriminator": { "properties": ["type", "kyc_type"], "values": ["individual/standard", "individual/enhanced", "business/standard"] }, + "sdk": [ + { "file": "src/Resources/Customers/Customers.php", "class": "CreateIndividualWithStandardKYCInput" }, + { "file": "src/Resources/Customers/Customers.php", "class": "CreateIndividualWithEnhancedKYCInput" }, + { "file": "src/Resources/Customers/Customers.php", "class": "CreateBusinessWithStandardKYBInput" } + ], + "note": "spec's kyc_type also has `light`, and business kyc_type also has `enhanced`; neither is implemented by this SDK -- known, pre-existing coverage gaps, tracked as Phase C items." + }, + { + "spec": "CustomerOut", + "discriminator": { "properties": ["type", "kyc_type"], "dispatchedBy": "Customers::mapCustomer()" }, + "sdk": [ + { "file": "src/Resources/Customers/Customers.php", "class": "BaseCustomer" }, + { "file": "src/Resources/Customers/Customers.php", "class": "IndividualWithStandardKYC" }, + { "file": "src/Resources/Customers/Customers.php", "class": "IndividualWithEnhancedKYC" }, + { "file": "src/Resources/Customers/Customers.php", "class": "BusinessWithStandardKYB" } + ] + }, + { "spec": "UpdateCustomerIn", "sdk": [ { "file": "src/Resources/Customers/Customers.php", "class": "UpdateCustomerInput" } ] }, + { "spec": "CustomerLimitIncreaseIn", "sdk": [ { "file": "src/Resources/Customers/Customers.php", "class": "RequestLimitIncreaseInput" } ] }, + { "spec": "CustomerLimitIncreaseOut", "sdk": [ { "file": "src/Resources/Customers/Customers.php", "class": "RequestLimitIncreaseResponse" } ] }, + { "spec": "GetCustomerLimitIncreaseOut", "sdk": [ { "file": "src/Resources/Customers/Customers.php", "class": "LimitIncreaseRequest" } ] }, + { "spec": "GetCustomerLimitsOut", "sdk": [ { "file": "src/Resources/Customers/Customers.php", "class": "GetCustomerLimitsResponse" } ] }, + { "spec": "QuoteIn", "sdk": [ { "file": "src/Resources/Quotes/Quotes.php", "class": "CreateQuoteInput" } ] }, + { "spec": "QuoteOut", "sdk": [ { "file": "src/Resources/Quotes/Quotes.php", "class": "CreateQuoteResponse" } ] }, + { "spec": "CreatePayinQuoteIn", "sdk": [ { "file": "src/Resources/Payins/Quotes.php", "class": "CreatePayinQuoteInput" } ] }, + { "spec": "CreatePayinQuoteOut", "sdk": [ { "file": "src/Resources/Payins/Quotes.php", "class": "CreatePayinQuoteResponse" } ] }, + { "spec": "CreateTransferQuoteIn", "sdk": [ { "file": "src/Resources/Transfers/Transfers.php", "class": "CreateTransferQuoteInput" } ] }, + { "spec": "CreateTransferQuoteOut", "sdk": [ { "file": "src/Resources/Transfers/Transfers.php", "class": "CreateTransferQuoteResponse" } ] }, + { "spec": "CreateTransferIn", "sdk": [ { "file": "src/Resources/Transfers/Transfers.php", "class": "CreateTransferInput" } ] }, + { "spec": "CreateTransferOut", "sdk": [ { "file": "src/Resources/Transfers/Transfers.php", "class": "Transfer" } ], "note": "Transfers::create() deserializes via Transfer::fromArray, the same class used for TransferOut." }, + { "spec": "TransferOut", "sdk": [ { "file": "src/Resources/Transfers/Transfers.php", "class": "Transfer" } ] }, + { "spec": "PayinOut", "sdk": [ { "file": "src/Resources/Payins/Payins.php", "class": "Payin" } ] }, + { "spec": "CreatePayinOut", "sdk": [ { "file": "src/Resources/Payins/Payins.php", "class": "CreateEvmPayinResponse" } ] }, + { "spec": "PayoutOut", "sdk": [ { "file": "src/Resources/Payouts/Payouts.php", "class": "Payout" } ] }, + { + "spec": "PayoutOnEvmOut", + "discriminator": { "note": "one shared response shape returned by createStellar/createEvm/createSolana", "sdkMethods": ["createStellar", "createEvm", "createSolana"] }, + "sdk": [ + { "file": "src/Resources/Payouts/Payouts.php", "class": "CreateStellarPayoutResponse" }, + { "file": "src/Resources/Payouts/Payouts.php", "class": "CreateEvmPayoutResponse" }, + { "file": "src/Resources/Payouts/Payouts.php", "class": "CreateSolanaPayoutResponse" } + ] + }, + { + "spec": "PayoutOnEvmIn", + "sdk": [ + { "file": "src/Resources/Payouts/Payouts.php", "class": "CreateStellarPayoutInput" }, + { "file": "src/Resources/Payouts/Payouts.php", "class": "CreateEvmPayoutInput" }, + { "file": "src/Resources/Payouts/Payouts.php", "class": "CreateSolanaPayoutInput" } + ] + }, + { "spec": "PayoutOnStellarAuthorizeIn", "sdk": [ { "file": "src/Resources/Payouts/Payouts.php", "class": "AuthorizeStellarTokenInput" } ] }, + { "spec": "SubmitPayoutDocumentsIn", "sdk": [ { "file": "src/Resources/Payouts/Payouts.php", "class": "SubmitPayoutDocumentsInput" } ] }, + { + "spec": ["PayoutOut", "PayoutOnEvmOut"], + "path": "tracking_payment", + "sdk": [ { "file": "src/Types/TrackingPayment.php", "class": "TrackingPayment" } ], + "note": "inline object, not a $ref'd component schema (no `Tracking*` schema exists in components.schemas); the same 20-property shape also recurs verbatim, unchecked here, in PayoutNewWebhookOut/PayoutUpdateWebhookOut/PayoutCompleteWebhookOut/PayoutPartnerFeeWebhookOut, which this SDK never deserializes (see 'webhook payloads' note below)." + }, + { + "spec": "PayinOut", + "path": "tracking_payment", + "sdk": [ { "file": "src/Types/TrackingPayment.php", "class": "TrackingPayment" } ], + "note": "same SDK class as the payout-side tracking_payment, but PayinOut's inline tracking_payment is a DIFFERENT, smaller shape (step/provider_name/completed_at/review_source/review_context/approved_risk_sources) than the payout one. TrackingPayment models step/provider_name/completed_at plus 3 payout-only fields (provider_transaction_id/provider_status/estimated_time_of_arrival) that PayinOut's tracking_payment does not have." + }, + { "spec": "CreateVirtualAccountIn", "sdk": [ { "file": "src/Resources/VirtualAccounts/VirtualAccounts.php", "class": "CreateVirtualAccountInput" } ] }, + { "spec": "UpdateVirtualAccountIn", "sdk": [ { "file": "src/Resources/VirtualAccounts/VirtualAccounts.php", "class": "UpdateVirtualAccountInput" } ] }, + { "spec": "VirtualAccountOut", "sdk": [ { "file": "src/Resources/VirtualAccounts/VirtualAccounts.php", "class": "VirtualAccount" } ] }, + { + "spec": "CreateBlockchainWalletIn", + "discriminator": { "note": "body-shape discriminated: address vs signature hash", "sdkMethods": ["createWithAddress", "createWithHash"] }, + "sdk": [ + { "file": "src/Resources/Wallets/BlockchainWallets.php", "class": "CreateBlockchainWalletWithAddressInput" }, + { "file": "src/Resources/Wallets/BlockchainWallets.php", "class": "CreateBlockchainWalletWithHashInput" } + ] + }, + { "spec": "BlockchainWalletOut", "sdk": [ { "file": "src/Resources/Wallets/BlockchainWallets.php", "class": "BlockchainWallet" } ] }, + { "spec": "BlockchainWalletMessageOut", "sdk": [ { "file": "src/Resources/Wallets/BlockchainWallets.php", "class": "GetBlockchainWalletMessageResponse" } ] }, + { "spec": "OfframpWallet", "sdk": [ { "file": "src/Resources/Wallets/OfframpWallets.php", "class": "OfframpWallet" } ] }, + { "spec": "CreateWalletIn", "sdk": [ { "file": "src/Resources/CustodialWallets/CustodialWallets.php", "class": "CreateCustodialWalletInput" } ], "note": "`/customers/{id}/wallets` is the custodial-wallets resource, not offramp wallets -- do not confuse with CreateOfframpWalletInput." }, + { "spec": "WalletOut", "sdk": [ { "file": "src/Resources/CustodialWallets/CustodialWallets.php", "class": "CustodialWallet" } ] }, + { "spec": "WalletBalanceOut", "sdk": [ { "file": "src/Resources/CustodialWallets/CustodialWallets.php", "class": "CustodialWalletBalanceResponse" } ] }, + { "spec": "WalletTokenOut", "sdk": [ { "file": "src/Resources/CustodialWallets/CustodialWallets.php", "class": "WalletTokenBalance" } ], "note": "embedded under WalletBalanceOut.USDC/USDT/USDB and WalletInboundSchemaOut.token; a real $ref'd component, not inline." }, + { "spec": "WebhookEndpoint", "sdk": [ { "file": "src/Resources/Webhooks/Webhooks.php", "class": "WebhookEndpoint" } ] }, + { "spec": "WebhookEndpointIn", "sdk": [ { "file": "src/Resources/Webhooks/Webhooks.php", "class": "CreateWebhookEndpointInput" } ] }, + { "spec": "WebhookEndpointOut", "sdk": [ { "file": "src/Resources/Webhooks/Webhooks.php", "class": "CreateWebhookEndpointResponse" } ] }, + { "spec": "InitiateTosIn", "sdk": [ { "file": "src/Resources/TermsOfService/TermsOfService.php", "class": "InitiateInput" } ] }, + { "spec": "InitiateTosOut", "sdk": [ { "file": "src/Resources/TermsOfService/TermsOfService.php", "class": "InitiateResponse" } ] }, + { "spec": "PortalAccessOut", "sdk": [ { "file": "src/Resources/Webhooks/Webhooks.php", "class": "GetPortalAccessUrlResponse" } ] }, + { "spec": "MigrateInstanceOwnershipIn", "sdk": [ { "file": "src/Resources/Ownership/Ownership.php", "class": "MigrateInstanceOwnershipIn" } ] }, + { "spec": "UpdateInstanceIn", "sdk": [ { "file": "src/Resources/Instances/Instances.php", "class": "UpdateInstanceInput" } ] }, + { "spec": "UpdateInstanceMemberIn", "sdk": [ { "file": "src/Resources/Instances/Instances.php", "class": "UpdateMemberRoleInput" } ], "note": "class has no toArray; the `user_role` wire key is built inline in Instances::updateMemberRole(), not on this DTO." }, + { "spec": "GetFeesOut", "sdk": [ { "file": "src/Resources/Fees/Fees.php", "class": "FeesResponse" } ] }, + { "spec": "FeeOptions", "sdk": [ { "file": "src/Resources/Fees/Fees.php", "class": "FeeOptions" } ] }, + { "spec": "SwiftCodeItem", "sdk": [ { "file": "src/Resources/Available/Available.php", "class": "SwiftCodeBankDetails" } ], "note": "spec uses camelCase wire keys here, unlike the rest of the API; SDK matches it verbatim." }, + { "spec": "AvailableRails", "sdk": [ { "file": "src/Resources/Available/Available.php", "class": "RailInfo" } ] }, + { "spec": "AvailableBankDetails", "sdk": [ { "file": "src/Resources/Available/Available.php", "class": "BankDetail" } ] }, + { "spec": "AvailableNaicsList", "sdk": [ { "file": "src/Resources/Available/Available.php", "class": "NaicsCode" } ] }, + { "spec": "UploadIn", "sdk": [ { "file": "src/Resources/Upload/Upload.php", "class": "UploadInput" } ] }, + { "spec": "UploadOut", "sdk": [ { "file": "src/Resources/Upload/Upload.php", "class": "UploadResponse" } ] }, + { "spec": "PaginationMetadata", "sdk": [ { "file": "src/Types/PaginationMetadata.php", "class": "PaginationMetadata" } ], "note": "referenced from the customers/payouts/payins/transfers list-response envelopes. next_page/prev_page type mismatch is a LIVE DEFECT, see known-divergences.json -- not fixed here." } + ], + "webhookPayloadsNote": "This SDK never deserializes webhook request bodies into typed classes -- BlindPay::verifyWebhookSignature() only verifies the signature and returns bool; callers get the raw payload. Consequently no `*WebhookOut` schema (BankAccountWebhookOut, BlockchainWalletWebhookOut, CustomerNewWebhookOut, CustomerUpdateWebhookOut, CustomerDeleteWebhookOut, PayinNewWebhookOut, PayinUpdateWebhookOut, PayinCompleteWebhookOut, PayinPartnerFeeWebhookOut, PayoutNewWebhookOut, PayoutUpdateWebhookOut, PayoutCompleteWebhookOut, PayoutPartnerFeeWebhookOut, LimitIncreaseNewWebhookOut, LimitIncreaseUpdateWebhookOut, TosAcceptWebhookOut) is mapped below; this is a structural fact about the SDK, not an oversight, and shows up as non-blocking coverage gaps rather than reconciliation failures.", + "ignore": { + "schemas": [ + { "schema": "LedgerOperation", "reason": "Unreferenced by any public operation or webhook (zero $ref anywhere in the spec) -- an orphan schema left over in components.schemas. Confirmed identical between .api-sync/spec-snapshot.json and the current spec." }, + { "schema": "Rfi", "reason": "RFI (request-for-information) resource is not implemented by this SDK. 4 operations, tracked as a Phase C item per the design doc." }, + { "schema": "RfiField", "reason": "Part of the unimplemented RFI resource, see Rfi." }, + { "schema": "RfiSection", "reason": "Part of the unimplemented RFI resource, see Rfi." }, + { "schema": "InstanceRfi", "reason": "Part of the unimplemented RFI resource, see Rfi." }, + { "schema": "UploadAnalyzeIn", "reason": "POST /v1/upload/analyze is not implemented by this SDK. Tracked as a Phase C item per the design doc." }, + { "schema": "UploadAnalyzeOut", "reason": "Part of the unimplemented upload/analyze operation, see UploadAnalyzeIn." } + ] + } +} diff --git a/.api-sync/spec-snapshot.json b/.api-sync/spec-snapshot.json index 2557f54..98de756 100644 --- a/.api-sync/spec-snapshot.json +++ b/.api-sync/spec-snapshot.json @@ -70,7 +70,7 @@ }, { "name": "Errors", - "description": "## Error responses\n\nEvery error response uses this envelope:\n\n```json\n{\n \"success\": false,\n \"code\": \"QUOTES_EXPIRED\",\n \"message\": \"quote_expired\",\n \"description\": \"This quote has expired. Request a new quote and try again.\",\n \"errors\": [],\n \"trace_id\": \"0af7651916cd43dd8448eb211c80319c\"\n}\n```\n\n- `code` is a stable identifier, safe to build programmatic handling on.\n- `message` is a legacy machine string kept for backward compatibility. Prefer `code`.\n- `description` is human-readable text, safe to show to end users.\n- `trace_id` identifies the request. Include it when contacting support.\n\nRetry semantics per code: `retryable` means the same request can be retried as is, `terminal` means do not retry, and `actionable` means the request succeeds only after you fix something (for example funding a balance or correcting bank details).\n\nThe Status column shows the HTTP status a code typically responds with; the per-endpoint error tabs list the exact status each endpoint returns.\n\n### Error codes\n\n| Code | Status | Retry | Description |\n| --- | --- | --- | --- |\n| `AUTH_EMAIL_ALREADY_REGISTERED` | 409 | terminal | This email address is already registered. |\n| `AUTH_FORBIDDEN` | 403 | terminal | You do not have permission to perform this action. |\n| `AUTH_IP_BLOCKED` | 403 | terminal | Requests from this IP address are not allowed for this API key. |\n| `AUTH_RATE_LIMITED` | 429 | retryable | Too many requests. Wait a moment and try again. |\n| `AUTH_SERVICE_UNAVAILABLE` | 503 | retryable | The authentication service is temporarily unavailable. Try again shortly. |\n| `AUTH_TOKEN_INVALID` | 401 | actionable | This link or token is invalid or has expired. Request a new one. |\n| `AUTH_UNAUTHORIZED` | 401 | actionable | Authentication failed. Check your API key or session and try again. |\n| `BANK_ACCOUNTS_INCOMPLETE` | 400 | actionable | The bank account details are incomplete for this payment method. Provide the missing fields. |\n| `BANK_ACCOUNTS_INVALID_BANK_CODE` | 400 | actionable | The bank code provided is not valid for this payment rail. The message lists where to fetch the valid codes. |\n| `BANK_ACCOUNTS_INVALID_ROUTING` | 400 | actionable | The routing number provided is not valid. Check the number and try again. |\n| `BANK_ACCOUNTS_NOT_APPROVED` | 403 | actionable | This bank account is not approved yet. |\n| `BANK_ACCOUNTS_NOT_FOUND` | 404 | actionable | The requested bank account was not found. |\n| `BANK_ACCOUNTS_PARTNER_NOT_SUPPORTED` | 400 | terminal | This banking option is not supported for this operation. |\n| `BANK_ACCOUNTS_RTP_NOT_SUPPORTED` | 400 | terminal | The provided routing number does not support instant payments. |\n| `BILLING_INVOICE_NOT_FOUND` | 404 | actionable | The requested invoice was not found. |\n| `BILLING_INVOICE_NOT_PAYABLE` | 409 | terminal | This invoice cannot be paid or collected in its current state. |\n| `BILLING_NOT_AVAILABLE` | 403 | terminal | Billing is not available for development instances. |\n| `BLOCKCHAIN_CALL_FAILED` | 400 | retryable | The blockchain call could not be completed. Check the transaction details and try again. |\n| `BLOCKCHAIN_NETWORK_NOT_SUPPORTED` | 400 | terminal | This network or token combination is not supported for this operation. |\n| `BLOCKCHAIN_TOKEN_NOT_SUPPORTED` | 400 | actionable | This token is not supported for this instance type. Development instances settle in USDB; production instances settle in USDC or USDT. |\n| `BLOCKCHAIN_TX_FAILED` | 502 | retryable | The blockchain transaction failed. Try again, and contact support if the problem persists. |\n| `COMPLIANCE_RFI_IN_PROGRESS` | 409 | actionable | An information request is already in progress. Resolve it before continuing. |\n| `COMPLIANCE_RFI_INVALID` | 400 | actionable | The information request could not be processed. Check the submitted fields. |\n| `CUSTOMERS_ALREADY_APPROVED` | 409 | terminal | This customer is already approved. |\n| `CUSTOMERS_BUSINESS_ENHANCED_NOT_AVAILABLE` | 400 | terminal | Enhanced verification is not available for business customers. Use standard verification (kyc_type: standard) for businesses. |\n| `CUSTOMERS_COUNTRY_NOT_SUPPORTED` | 400 | terminal | This country is not supported for this operation. |\n| `CUSTOMERS_ENHANCED_KYC_REQUIRED` | 400 | actionable | Individuals from high-risk countries must use enhanced verification (kyc_type: enhanced); standard verification is not available for them. See https://www.blindpay.com/docs/kb/supported-countries for country tiers. |\n| `CUSTOMERS_ID_DOCUMENT_INVALID` | 400 | actionable | The identity document is not an accepted passport or national ID. Provide a valid passport or national ID for this customer (a driver license is not accepted for high-risk countries). |\n| `CUSTOMERS_INVALID_DATA` | 422 | actionable | The customer data was rejected by the banking provider. Check the highlighted fields and try again. |\n| `CUSTOMERS_INVALID_PHONE` | 400 | actionable | The phone number provided is not valid. Check the number and try again. |\n| `CUSTOMERS_INVALID_TAX_ID` | 400 | actionable | The tax ID provided is not valid. Check the value and try again. |\n| `CUSTOMERS_KYC_NOT_APPROVED` | 403 | actionable | This customer has not completed verification yet. Complete the verification before continuing. |\n| `CUSTOMERS_NOT_FOUND` | 404 | actionable | The requested customer was not found. |\n| `CUSTOMERS_ONBOARDING_INCOMPLETE` | 400 | actionable | Onboarding is not complete yet. Finish the remaining steps before continuing. |\n| `DATA_INTEGRITY_ERROR` | 422 | terminal | This record could not be loaded because it contains invalid stored data. Contact support with the ID below. |\n| `FEES_NOT_FOUND` | 404 | actionable | The requested fee configuration was not found. |\n| `FEES_PARTNER_FEE_EXCEEDED` | 400 | actionable | The partner fee exceeds the allowed maximum for this transaction. |\n| `FILES_ANALYSIS_FAILED` | 502 | retryable | The document could not be analyzed. Try again, and contact support if the problem persists. |\n| `FILES_EMPTY` | 400 | actionable | The uploaded file is empty. Upload a valid file. |\n| `FILES_TOO_LARGE` | 400 | actionable | The file is too large. Upload a smaller file. |\n| `FILES_TYPE_NOT_ALLOWED` | 400 | actionable | This file type is not allowed. Upload a supported file type. |\n| `FILES_UNREADABLE` | 400 | actionable | We could not read the uploaded file. Check the file and try again. |\n| `INSTANCES_BLOCKED` | 403 | terminal | This account is blocked. Contact support for assistance. |\n| `INSTANCES_LIMIT_REACHED` | 400 | terminal | You reached the maximum number of instances allowed for your account. |\n| `INSTANCES_NOT_FOUND` | 404 | actionable | The requested instance was not found. |\n| `INTERNAL_ERROR` | 500 | retryable | Something went wrong on our side. Try again, and contact support with the trace ID if the problem persists. |\n| `LIMITS_AMOUNT_OUT_OF_RANGE` | 400 | actionable | The amount is outside the allowed range. The message includes the allowed range. |\n| `LIMITS_VOLUME_EXCEEDED` | 400 | actionable | You exceeded your transaction volume limit. The message includes the remaining amount available. |\n| `PAYINS_ALREADY_TERMINAL` | 409 | terminal | This payin already reached a final status and cannot be changed. |\n| `PAYINS_METHOD_NOT_SUPPORTED` | 400 | terminal | This payment method is not supported for this operation. |\n| `PAYINS_NOT_FOUND` | 404 | actionable | The requested payin was not found. |\n| `PAYINS_TAX_ID_REQUIRED` | 400 | actionable | A tax ID is required for this payment method. Provide the tax ID and try again. |\n| `PAYOUTS_ALLOWANCE_NOT_CONFIRMED` | 400 | retryable | The token approval for this payout has not been confirmed on-chain yet, or the approved amount is lower than the payout amount. Wait for the approval transaction to confirm and execute the quote again. |\n| `PAYOUTS_AMOUNT_BELOW_MINIMUM` | 400 | actionable | The amount is below the minimum for this payment rail. |\n| `PAYOUTS_DOCUMENTS_NOT_ACCEPTED` | 400 | terminal | Document upload is only available for international wire payouts. |\n| `PAYOUTS_INSUFFICIENT_BALANCE` | 400 | actionable | There is not enough balance to complete this payout. Fund your balance and try again. |\n| `PAYOUTS_NOT_FOUND` | 404 | actionable | The requested payout was not found. |\n| `QUOTES_ALREADY_USED` | 409 | actionable | This quote was already used. Request a new quote. |\n| `QUOTES_EXPIRED` | 400 | actionable | This quote has expired. Request a new quote and try again. |\n| `QUOTES_INSUFFICIENT_LIQUIDITY` | 400 | actionable | There is not enough market liquidity to fill this amount right now. Try a smaller amount or try again later. |\n| `QUOTES_INVALID_STATE` | 400 | actionable | This quote cannot be authorized in its current state. Request a new quote and try again. |\n| `QUOTES_NOT_FOUND` | 404 | actionable | The requested quote was not found. |\n| `QUOTES_OTC_NOT_SUPPORTED` | 400 | actionable | This OTC configuration is not supported. OTC requires BRL with USDT, and either the sender amount with fees not covered, or the receiver amount with fees covered. |\n| `QUOTES_RATE_UNAVAILABLE` | 502 | retryable | An exchange rate is temporarily unavailable. Try again shortly. |\n| `TOS_ALREADY_ACCEPTED` | 409 | terminal | These terms of service were already accepted. |\n| `TOS_NOT_ACCEPTED` | 400 | actionable | The terms of service must be accepted before continuing. |\n| `TOS_NOT_FOUND` | 404 | actionable | The requested terms of service record was not found. |\n| `TRANSFERS_NOT_ENABLED` | 403 | terminal | Wallets and transfers are not enabled for this account. Contact support to enable them. |\n| `VALIDATION_FAILED` | 400 | actionable | The request contains invalid fields. Check the errors list for details. |\n| `VALIDATION_INVALID_REQUEST` | 400 | actionable | The request is not valid for this operation. Check the parameters and try again. |\n| `VALIDATION_MISSING_BODY` | 400 | actionable | The request body is missing or malformed. Send valid JSON with the Content-Type: application/json header. |\n| `VALIDATION_MISSING_REQUIRED_FIELDS` | 400 | actionable | Required fields are missing. Provide the missing fields and try again. |\n| `VIRTUAL_ACCOUNTS_DISABLED` | 403 | terminal | Virtual accounts are not enabled for this account. Contact support to enable them. |\n| `VIRTUAL_ACCOUNTS_DOCUMENTS_REQUIRED` | 400 | actionable | Additional documents are required to create this virtual account. Upload the requested documents. |\n| `VIRTUAL_ACCOUNTS_NOT_APPROVED` | 403 | actionable | This virtual account is not approved yet. |\n| `VIRTUAL_ACCOUNTS_NOT_FOUND` | 404 | actionable | The requested virtual account was not found. |\n| `VIRTUAL_ACCOUNTS_PROVISION_FAILED` | 502 | retryable | We could not create the virtual account. Try again, and contact support if the problem persists. |\n| `VIRTUAL_ACCOUNTS_REGION_NOT_SUPPORTED` | 400 | terminal | Virtual accounts are not available for this country or region. |\n| `WALLETS_ATTACHED_TO_VIRTUAL_ACCOUNT` | 400 | terminal | This wallet is attached to a virtual account and cannot be deleted. Delete the virtual account first. |\n| `WALLETS_BALANCE_UNAVAILABLE` | 503 | retryable | The wallet balance is temporarily unavailable. Try again shortly. |\n| `WALLETS_NOT_FOUND` | 404 | actionable | The requested wallet was not found. |\n| `WEBHOOKS_LIMIT_REACHED` | 400 | terminal | You reached the maximum number of webhook endpoints. |\n| `WEBHOOKS_SIGNATURE_INVALID` | 401 | terminal | The webhook signature is missing or invalid. |\n| `WEBHOOKS_URL_INVALID` | 400 | actionable | The webhook URL is not valid. Use a reachable HTTPS URL. |\n" + "description": "## Error responses\n\nEvery error response uses this envelope:\n\n```json\n{\n \"success\": false,\n \"code\": \"QUOTES_EXPIRED\",\n \"message\": \"quote_expired\",\n \"description\": \"This quote has expired. Request a new quote and try again.\",\n \"errors\": [],\n \"trace_id\": \"0af7651916cd43dd8448eb211c80319c\"\n}\n```\n\n- `code` is a stable identifier, safe to build programmatic handling on.\n- `message` is a legacy machine string kept for backward compatibility. Prefer `code`.\n- `description` is human-readable text, safe to show to end users.\n- `trace_id` identifies the request. Include it when contacting support.\n\nRetry semantics per code: `retryable` means the same request can be retried as is, `terminal` means do not retry, and `actionable` means the request succeeds only after you fix something (for example funding a balance or correcting bank details).\n\nThe Status column shows the HTTP status a code typically responds with; the per-endpoint error tabs list the exact status each endpoint returns.\n\n### Error codes\n\n| Code | Status | Retry | Description |\n| --- | --- | --- | --- |\n| `AUTH_EMAIL_ALREADY_REGISTERED` | 409 | terminal | This email address is already registered. |\n| `AUTH_FORBIDDEN` | 403 | terminal | You do not have permission to perform this action. |\n| `AUTH_IP_BLOCKED` | 403 | terminal | Requests from this IP address are not allowed for this API key. |\n| `AUTH_RATE_LIMITED` | 429 | retryable | Too many requests. Wait a moment and try again. |\n| `AUTH_SERVICE_UNAVAILABLE` | 503 | retryable | The authentication service is temporarily unavailable. Try again shortly. |\n| `AUTH_TOKEN_INVALID` | 401 | actionable | This link or token is invalid or has expired. Request a new one. |\n| `AUTH_UNAUTHORIZED` | 401 | actionable | Authentication failed. Check your API key or session and try again. |\n| `BANK_ACCOUNTS_INCOMPLETE` | 400 | actionable | The bank account details are incomplete for this payment method. Provide the missing fields. |\n| `BANK_ACCOUNTS_INVALID_BANK_CODE` | 400 | actionable | The bank code provided is not valid for this payment rail. The message lists where to fetch the valid codes. |\n| `BANK_ACCOUNTS_INVALID_ROUTING` | 400 | actionable | The routing number provided is not valid. Check the number and try again. |\n| `BANK_ACCOUNTS_NOT_APPROVED` | 403 | actionable | This bank account is not approved yet. |\n| `BANK_ACCOUNTS_NOT_FOUND` | 404 | actionable | The requested bank account was not found. |\n| `BANK_ACCOUNTS_PARTNER_NOT_SUPPORTED` | 400 | terminal | This banking option is not supported for this operation. |\n| `BANK_ACCOUNTS_RTP_NOT_SUPPORTED` | 400 | terminal | The provided routing number does not support instant payments. |\n| `BILLING_INVOICE_NOT_FOUND` | 404 | actionable | The requested invoice was not found. |\n| `BILLING_INVOICE_NOT_PAYABLE` | 409 | terminal | This invoice cannot be paid or collected in its current state. |\n| `BILLING_NOT_AVAILABLE` | 403 | terminal | Billing is not available for development instances. |\n| `BLOCKCHAIN_CALL_FAILED` | 400 | retryable | The blockchain call could not be completed. Check the transaction details and try again. |\n| `BLOCKCHAIN_NETWORK_NOT_SUPPORTED` | 400 | terminal | This network or token combination is not supported for this operation. |\n| `BLOCKCHAIN_TOKEN_NOT_SUPPORTED` | 400 | actionable | This token is not supported for this instance type. Development instances settle in USDB; production instances settle in USDC or USDT. |\n| `BLOCKCHAIN_TX_FAILED` | 502 | retryable | The blockchain transaction failed. Try again, and contact support if the problem persists. |\n| `COMPLIANCE_RFI_IN_PROGRESS` | 409 | actionable | An information request is already in progress. Resolve it before continuing. |\n| `COMPLIANCE_RFI_INVALID` | 400 | actionable | The information request could not be processed. Check the submitted fields. |\n| `CUSTOMERS_ALREADY_APPROVED` | 409 | terminal | This customer is already approved. |\n| `CUSTOMERS_BUSINESS_ENHANCED_NOT_AVAILABLE` | 400 | terminal | Enhanced verification is not available for business customers. Use standard verification (kyc_type: standard) for businesses. |\n| `CUSTOMERS_COUNTRY_NOT_SUPPORTED` | 400 | terminal | This country is not supported for this operation. |\n| `CUSTOMERS_ENHANCED_KYC_REQUIRED` | 400 | actionable | Individuals from high-risk countries must use enhanced verification (kyc_type: enhanced); standard verification is not available for them. See https://www.blindpay.com/docs/kb/supported-countries for country tiers. |\n| `CUSTOMERS_ID_DOCUMENT_INVALID` | 400 | actionable | The identity document is not an accepted passport or national ID. Provide a valid passport or national ID for this customer (a driver license is not accepted for high-risk countries). |\n| `CUSTOMERS_INVALID_DATA` | 422 | actionable | The customer data was rejected by the banking provider. Check the highlighted fields and try again. |\n| `CUSTOMERS_INVALID_PHONE` | 400 | actionable | The phone number provided is not valid. Check the number and try again. |\n| `CUSTOMERS_INVALID_TAX_ID` | 400 | actionable | The tax ID provided is not valid. Check the value and try again. |\n| `CUSTOMERS_KYC_NOT_APPROVED` | 403 | actionable | This customer has not completed verification yet. Complete the verification before continuing. |\n| `CUSTOMERS_NOT_FOUND` | 404 | actionable | The requested customer was not found. |\n| `CUSTOMERS_ONBOARDING_INCOMPLETE` | 400 | actionable | Onboarding is not complete yet. Finish the remaining steps before continuing. |\n| `DATA_INTEGRITY_ERROR` | 422 | terminal | This record could not be loaded because it contains invalid stored data. Contact support with the ID below. |\n| `FEES_NOT_FOUND` | 404 | actionable | The requested fee configuration was not found. |\n| `FEES_PARTNER_FEE_EXCEEDED` | 400 | actionable | The partner fee exceeds the allowed maximum for this transaction. |\n| `FILES_ANALYSIS_FAILED` | 502 | retryable | The document could not be analyzed. Try again, and contact support if the problem persists. |\n| `FILES_EMPTY` | 400 | actionable | The uploaded file is empty. Upload a valid file. |\n| `FILES_TOO_LARGE` | 400 | actionable | The file is too large. Upload a smaller file. |\n| `FILES_TYPE_NOT_ALLOWED` | 400 | actionable | This file type is not allowed. Upload a supported file type. |\n| `FILES_UNREADABLE` | 400 | actionable | We could not read the uploaded file. Check the file and try again. |\n| `INSTANCES_BLOCKED` | 403 | terminal | This account is blocked. Contact support for assistance. |\n| `INSTANCES_LIMIT_REACHED` | 400 | terminal | You reached the maximum number of instances allowed for your account. |\n| `INSTANCES_NOT_FOUND` | 404 | actionable | The requested instance was not found. |\n| `INTERNAL_ERROR` | 500 | retryable | Something went wrong on our side. Try again, and contact support with the trace ID if the problem persists. |\n| `LIMITS_AMOUNT_OUT_OF_RANGE` | 400 | actionable | The amount is outside the allowed range. The message includes the allowed range. |\n| `LIMITS_VOLUME_EXCEEDED` | 400 | actionable | You exceeded your transaction volume limit. The message includes the remaining amount available. |\n| `PAYINS_ALREADY_TERMINAL` | 409 | terminal | This payin already reached a final status and cannot be changed. |\n| `PAYINS_METHOD_NOT_SUPPORTED` | 400 | terminal | This payment method is not supported for this operation. |\n| `PAYINS_NOT_FOUND` | 404 | actionable | The requested payin was not found. |\n| `PAYINS_TAX_ID_REQUIRED` | 400 | actionable | A tax ID is required for this payment method. Provide the tax ID and try again. |\n| `PAYOUTS_ALLOWANCE_NOT_CONFIRMED` | 400 | retryable | The token approval for this payout has not been confirmed on-chain yet, or the approved amount is lower than the payout amount. Wait for the approval transaction to confirm and execute the quote again. |\n| `PAYOUTS_AMOUNT_BELOW_MINIMUM` | 400 | actionable | The amount is below the minimum for this payment rail. |\n| `PAYOUTS_DOCUMENTS_NOT_ACCEPTED` | 400 | terminal | Document upload is only available for international wire payouts. |\n| `PAYOUTS_INSUFFICIENT_BALANCE` | 400 | actionable | There is not enough balance to complete this payout. Fund your balance and try again. |\n| `PAYOUTS_NOT_FOUND` | 404 | actionable | The requested payout was not found. |\n| `QUOTES_ALREADY_USED` | 409 | actionable | This quote was already used. Request a new quote. |\n| `QUOTES_EXPIRED` | 400 | actionable | This quote has expired. Request a new quote and try again. |\n| `QUOTES_INSUFFICIENT_LIQUIDITY` | 400 | actionable | There is not enough market liquidity to fill this amount right now. Try a smaller amount or try again later. |\n| `QUOTES_INVALID_STATE` | 400 | actionable | This quote cannot be authorized in its current state. Request a new quote and try again. |\n| `QUOTES_NOT_FOUND` | 404 | actionable | The requested quote was not found. |\n| `QUOTES_OTC_NOT_SUPPORTED` | 400 | actionable | This OTC configuration is not supported. OTC requires BRL with USDT, and either the sender amount with fees not covered, or the receiver amount with fees covered. |\n| `QUOTES_RATE_UNAVAILABLE` | 502 | retryable | An exchange rate is temporarily unavailable. Try again shortly. |\n| `REQUEST_REJECTED` | 400 | actionable | The request was rejected. Check the error message for details. |\n| `TOS_ALREADY_ACCEPTED` | 409 | terminal | These terms of service were already accepted. |\n| `TOS_NOT_ACCEPTED` | 400 | actionable | The terms of service must be accepted before continuing. |\n| `TOS_NOT_FOUND` | 404 | actionable | The requested terms of service record was not found. |\n| `TRANSFERS_NOT_ENABLED` | 403 | terminal | Wallets and transfers are not enabled for this account. Contact support to enable them. |\n| `VALIDATION_FAILED` | 400 | actionable | The request contains invalid fields. Check the errors list for details. |\n| `VALIDATION_INVALID_REQUEST` | 400 | actionable | The request is not valid for this operation. Check the parameters and try again. |\n| `VALIDATION_MISSING_BODY` | 400 | actionable | The request body is missing or malformed. Send valid JSON with the Content-Type: application/json header. |\n| `VALIDATION_MISSING_REQUIRED_FIELDS` | 400 | actionable | Required fields are missing. Provide the missing fields and try again. |\n| `VIRTUAL_ACCOUNTS_DISABLED` | 403 | terminal | Virtual accounts are not enabled for this account. Contact support to enable them. |\n| `VIRTUAL_ACCOUNTS_DOCUMENTS_REQUIRED` | 400 | actionable | Additional documents are required to create this virtual account. Upload the requested documents. |\n| `VIRTUAL_ACCOUNTS_NOT_APPROVED` | 403 | actionable | This virtual account is not approved yet. |\n| `VIRTUAL_ACCOUNTS_NOT_FOUND` | 404 | actionable | The requested virtual account was not found. |\n| `VIRTUAL_ACCOUNTS_PROVISION_FAILED` | 502 | retryable | We could not create the virtual account. Try again, and contact support if the problem persists. |\n| `VIRTUAL_ACCOUNTS_REGION_NOT_SUPPORTED` | 400 | terminal | Virtual accounts are not available for this country or region. |\n| `WALLETS_ATTACHED_TO_VIRTUAL_ACCOUNT` | 400 | terminal | This wallet is attached to a virtual account and cannot be deleted. Delete the virtual account first. |\n| `WALLETS_BALANCE_UNAVAILABLE` | 503 | retryable | The wallet balance is temporarily unavailable. Try again shortly. |\n| `WALLETS_NOT_FOUND` | 404 | actionable | The requested wallet was not found. |\n| `WEBHOOKS_LIMIT_REACHED` | 400 | terminal | You reached the maximum number of webhook endpoints. |\n| `WEBHOOKS_SIGNATURE_INVALID` | 401 | terminal | The webhook signature is missing or invalid. |\n| `WEBHOOKS_URL_INVALID` | 400 | actionable | The webhook URL is not valid. Use a reachable HTTPS URL. |\n" } ], "security": [ @@ -3250,6 +3250,11 @@ "example": "bh_swift_afl" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -4747,9 +4752,19 @@ "example": "re_000000000000" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -4871,9 +4886,19 @@ "example": "re_000000000000" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -10526,9 +10551,19 @@ } }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "limit": { @@ -12567,9 +12602,19 @@ } }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "limit": { @@ -14238,9 +14283,19 @@ "example": "f06dbc45-58a4-389f-beb8-581c3fafff3c" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "image_url": { @@ -17355,6 +17410,11 @@ "example": "abcd1234" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -17451,6 +17511,11 @@ ] }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -17625,9 +17690,19 @@ "$ref": "#/components/schemas/FeeOptions" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -17944,9 +18019,19 @@ "example": "in_000000000000" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -18090,9 +18175,19 @@ "example": "in_000000000000" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -18604,9 +18699,19 @@ "example": null }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "image_url": { @@ -21188,9 +21293,19 @@ ] }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "image_url": { @@ -21937,9 +22052,19 @@ "example": "re_000000000000" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -22012,9 +22137,19 @@ "example": "TALJN9zTTEL9TVBb4WuTt6wLvPqJZr3hvb" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -22077,6 +22212,11 @@ "example": "polygon" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -25360,6 +25500,11 @@ "example": "bh_swift_afl" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -32561,9 +32706,19 @@ } }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "limit": { @@ -35656,6 +35811,11 @@ "example": "https://example.com/image.png" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "has_passkey": { diff --git a/.api-sync/unmodeled.json b/.api-sync/unmodeled.json new file mode 100644 index 0000000..90dba38 --- /dev/null +++ b/.api-sync/unmodeled.json @@ -0,0 +1,604 @@ +{ + "$schema": "Seeded state-reconciliation baseline: every spec property, on a schema mapped in .api-sync/spec-map.json, that scripts/api-sync.php currently finds absent from its mapped SDK class(es). Each entry needs a specific reason and an owner. `--check` treats anything absent from both the SDK and this file as pending drift and fails. Do not add an entry for a field this PR's changelist is adding instead.", + "entries": [ + { + "schema": "BankAccountOut", + "field": "business_industry", + "reason": "Compliance metadata captured on ACH/wire/RTP/SWIFT bank-account creation; the shared list/get response type (BankAccountListItem) does not echo it back.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "date_of_birth", + "reason": "Beneficiary date of birth captured on ACH/wire/RTP/SWIFT bank-account creation; not echoed back on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "phone_number", + "reason": "Beneficiary phone number captured on ACH/wire/RTP/SWIFT bank-account creation; not echoed back on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "tax_id", + "reason": "Beneficiary tax ID captured on ACH/wire/RTP/SWIFT bank-account creation; not echoed back on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "recipient_relationship", + "reason": "Modeled on the rail-specific create Input/Response classes (ACH/wire/RTP/SWIFT) but not surfaced on the shared list/get response type (BankAccountListItem).", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "status", + "reason": "Bank account verification status (verifying/approved/rejected/deprecated); not modeled on any bank-account response type in this SDK.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "pix_safe_bank_code", + "reason": "PIX Safe rail field modeled on create (CreatePixSafeInput/CreatePixSafeResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "pix_safe_branch_code", + "reason": "PIX Safe rail field modeled on create (CreatePixSafeInput/CreatePixSafeResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "pix_safe_cpf_cnpj", + "reason": "PIX Safe rail field modeled on create (CreatePixSafeInput/CreatePixSafeResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "sepa_beneficiary_address_line_1", + "reason": "SEPA rail field modeled on create (CreateSepaInput/CreateSepaResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "sepa_beneficiary_address_line_2", + "reason": "SEPA rail field modeled on create (CreateSepaInput/CreateSepaResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "sepa_beneficiary_city", + "reason": "SEPA rail field modeled on create (CreateSepaInput/CreateSepaResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "sepa_beneficiary_country", + "reason": "SEPA rail field modeled on create (CreateSepaInput/CreateSepaResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "sepa_beneficiary_legal_name", + "reason": "SEPA rail field modeled on create (CreateSepaInput/CreateSepaResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "sepa_beneficiary_postal_code", + "reason": "SEPA rail field modeled on create (CreateSepaInput/CreateSepaResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "sepa_beneficiary_state_province_region", + "reason": "SEPA rail field modeled on create (CreateSepaInput/CreateSepaResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "sepa_iban", + "reason": "SEPA rail field modeled on create (CreateSepaInput/CreateSepaResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "swift_ifsc_branch_code", + "reason": "India-specific SWIFT branch code; not modeled on any SWIFT bank-account type in this SDK.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "swift_payment_code", + "reason": "SWIFT payment/purpose code; not modeled on any SWIFT bank-account type in this SDK.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateBankAccountIn", + "field": "checkbook_account_id", + "reason": "Internal payment-provider account-linkage field; not exposed as a constructor parameter on any rail's create Input class.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateBankAccountIn", + "field": "checkbook_user_key", + "reason": "Internal payment-provider account-linkage field; not exposed as a constructor parameter on any rail's create Input class.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateBankAccountIn", + "field": "onemoney_external_account_id", + "reason": "Internal payment-provider account-linkage field; not exposed as a constructor parameter on any rail's create Input class.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateBankAccountIn", + "field": "force_cpf_cnpj", + "reason": "Brazil-specific override flag; not exposed as a constructor parameter on any rail's create Input class.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateBankAccountIn", + "field": "status", + "reason": "Bank account status is server-assigned on creation, not client-set; no create Input class accepts it.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateBankAccountIn", + "field": "swift_ifsc_branch_code", + "reason": "India-specific SWIFT branch code; not modeled on CreateInternationalSwiftInput.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateCustomerIn", + "field": "additional_info", + "reason": "Free-form key/value compliance metadata; not exposed as a constructor parameter on any of the 3 create Input classes.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateCustomerIn", + "field": "latitude", + "reason": "Geolocation captured at KYC submission time; not exposed as a constructor parameter on any of the 3 create Input classes.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateCustomerIn", + "field": "longitude", + "reason": "Geolocation captured at KYC submission time; not exposed as a constructor parameter on any of the 3 create Input classes.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CustomerOut", + "field": "additional_info", + "reason": "Free-form key/value compliance metadata; not modeled on BaseCustomer or any of its 3 subclasses.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CustomerOut", + "field": "customer_id", + "reason": "Response envelope carries both `id` (modeled) and a duplicate `customer_id`; not modeled on BaseCustomer.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CustomerOut", + "field": "kyc_type", + "reason": "Used by Customers::mapCustomer() to select which subclass to instantiate, but not itself stored as a field on any subclass afterwards.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CustomerOut", + "field": "latitude", + "reason": "Geolocation captured at KYC submission time; not modeled on BaseCustomer or any of its 3 subclasses.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CustomerOut", + "field": "longitude", + "reason": "Geolocation captured at KYC submission time; not modeled on BaseCustomer or any of its 3 subclasses.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CustomerOut", + "field": "type", + "reason": "Used by Customers::mapCustomer() to select which subclass to instantiate, but not itself stored as a field on any subclass afterwards.", + "owner": "eric@blindpay.com" + }, + { + "schema": "UpdateCustomerIn", + "field": "additional_info", + "reason": "Free-form key/value compliance metadata; not exposed as a constructor parameter on UpdateCustomerInput.", + "owner": "eric@blindpay.com" + }, + { + "schema": "UpdateCustomerIn", + "field": "latitude", + "reason": "Geolocation; not exposed as a constructor parameter on UpdateCustomerInput.", + "owner": "eric@blindpay.com" + }, + { + "schema": "UpdateCustomerIn", + "field": "longitude", + "reason": "Geolocation; not exposed as a constructor parameter on UpdateCustomerInput.", + "owner": "eric@blindpay.com" + }, + { + "schema": "QuoteOut", + "field": "billing_fee_amount", + "reason": "Fee breakdown field; CreateQuoteResponse models flat_fee and partner_fee_amount but not this separate billing_fee_amount.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreatePayinQuoteOut", + "field": "billing_fee_amount", + "reason": "Fee breakdown field; CreatePayinQuoteResponse models flat_fee and partner_fee_amount but not this separate billing_fee_amount.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreatePayinQuoteOut", + "field": "is_otc", + "reason": "OTC (over-the-counter) quote flag; not modeled on CreatePayinQuoteResponse.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayinOut", + "field": "manual_concluded_at", + "reason": "Manual-review audit timestamp; not modeled on Payin.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayinOut", + "field": "manual_concluded_by", + "reason": "Manual-review audit actor; not modeled on Payin.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayinOut", + "field": "partner_fee", + "reason": "Nested partner-fee detail object; Payin models the flat partner_fee_amount/partner_fee_id pair but not this nested object.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayinOut", + "field": "payer_rules", + "reason": "Mirrors the payer_rules object sent on CreatePayinQuoteInput, but the payin response does not echo it back and Payin does not model it.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayinOut", + "field": "transaction_fee_amount", + "reason": "Fee breakdown field; not modeled on Payin (which models total_fee_amount/partner_fee_amount but not this one).", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreatePayinOut", + "field": "billing_fee_amount", + "reason": "CreateEvmPayinResponse is a slim create-response shape; does not model this fee breakdown field (present on the full Payin/PayinOut shape considerations aside).", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreatePayinOut", + "field": "partner_fee", + "reason": "Nested partner-fee detail object; not modeled on the slim CreateEvmPayinResponse.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreatePayinOut", + "field": "payment_method", + "reason": "Not modeled on the slim CreateEvmPayinResponse (this create path is EVM-only, so the method is implicit).", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreatePayinOut", + "field": "sender_amount", + "reason": "Not modeled on the slim CreateEvmPayinResponse, which only models receiver_amount.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreatePayinOut", + "field": "transaction_fee_amount", + "reason": "Fee breakdown field; not modeled on the slim CreateEvmPayinResponse.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "field": "jpm_track_data", + "reason": "Internal banking-partner tracking blob; not modeled on Payout.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "field": "partner_fee", + "reason": "Nested partner-fee detail object; Payout models the flat partner_fee_amount/partner_fee_id pair but not this nested object.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "field": "pix_safe_bank_code", + "reason": "PIX Safe rail field; not surfaced on the generic Payout response (mirrors the same gap on BankAccountOut).", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "field": "pix_safe_branch_code", + "reason": "PIX Safe rail field; not surfaced on the generic Payout response (mirrors the same gap on BankAccountOut).", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "field": "pix_safe_cpf_cnpj", + "reason": "PIX Safe rail field; not surfaced on the generic Payout response (mirrors the same gap on BankAccountOut).", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "field": "tracking_documents", + "reason": "SWIFT payout document-request tracking sub-object; no SDK class models it at all (no TrackingDocuments type exists).", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "field": "transaction_fee_amount", + "reason": "Fee breakdown field; not modeled on Payout (which models total_fee_amount/partner_fee_amount but not this one).", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOnEvmOut", + "field": "bank_account_id", + "reason": "Not modeled on the slim CreateStellarPayoutResponse/CreateEvmPayoutResponse/CreateSolanaPayoutResponse create-response shapes.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOnEvmOut", + "field": "billing_fee_amount", + "reason": "Fee breakdown field; not modeled on the slim create-response shapes.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOnEvmOut", + "field": "offramp_wallet_id", + "reason": "Not modeled on the slim create-response shapes.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOnEvmOut", + "field": "partner_fee", + "reason": "Nested partner-fee detail object; not modeled on the slim create-response shapes.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOnEvmOut", + "field": "tracking_documents", + "reason": "SWIFT payout document-request tracking sub-object; no SDK class models it at all, same gap as PayoutOut.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOnEvmOut", + "field": "transaction_fee_amount", + "reason": "Fee breakdown field; not modeled on the slim create-response shapes.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "provider_integration", + "reason": "Payout tracking_payment carries 20 properties in the current spec; TrackingPayment models 6 (step/provider_name/provider_transaction_id/provider_status/estimated_time_of_arrival/completed_at). Exposing the remaining payout-only fields (this one plus provider_error_reason, provider_uetr, provider_imad, provider_reference, provider_clearing_system, recipient_name, recipient_tax_id, recipient_bank_code, recipient_branch_code, recipient_account_number, recipient_account_type, coelsa_id, end_to_end_id) is a deliberate additive change that needs one reviewed PR across all SDKs, not a blind per-field add here.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "provider_error_reason", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "provider_uetr", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "provider_imad", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "provider_reference", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch. Provider payment reference captured from the banking partner's booking webhook; present for some ACH/wire payouts.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "provider_clearing_system", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "recipient_name", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "recipient_tax_id", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "recipient_bank_code", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "recipient_branch_code", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "recipient_account_number", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "recipient_account_type", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "coelsa_id", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "end_to_end_id", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayinOut", + "path": "tracking_payment", + "field": "review_source", + "reason": "Payin-side tracking_payment carries manual-review metadata (review_source/review_context/approved_risk_sources) that the shared TrackingPayment class, modeled after the payout shape, does not have. Same deferred-batch reasoning as the payout tracking_payment fields: needs a reviewed PR, not a blind add onto a class shared with payouts.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayinOut", + "path": "tracking_payment", + "field": "review_context", + "reason": "See review_source on this same schema/path -- part of the same deferred payin tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayinOut", + "path": "tracking_payment", + "field": "approved_risk_sources", + "reason": "See review_source on this same schema/path -- part of the same deferred payin tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "VirtualAccountOut", + "field": "blockchain_wallet", + "reason": "Nested embedded wallet object; VirtualAccount only stores blockchainWalletId (the ID string), not the embedded object.", + "owner": "eric@blindpay.com" + }, + { + "schema": "VirtualAccountOut", + "field": "customer_id", + "reason": "Not modeled on VirtualAccount; callers already have the customer_id they used to make the request.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateWalletIn", + "field": "name", + "reason": "LIVE DEFECT, not cosmetic: `name` is REQUIRED on CreateWalletIn (spec `required: [\"network\",\"name\"]`), but CreateCustodialWalletInput's constructor accepts only customerId and network. Every custodial-wallet-creation call through this SDK omits a required field and is rejected by the API -- custodial wallet creation is completely broken through this SDK today. node, go and swift all send `name`; python and php do not. Not fixed here: this needs a required (non-defaulted) constructor parameter, which is a breaking API-shape change to CreateCustodialWalletInput, not a mechanical optional-field add.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateWalletIn", + "field": "external_id", + "reason": "Caller-supplied external ID; not exposed as a constructor parameter on CreateCustodialWalletInput.", + "owner": "eric@blindpay.com" + }, + { + "schema": "WalletOut", + "field": "name", + "reason": "Wallet display name; not modeled on CustodialWallet.", + "owner": "eric@blindpay.com" + }, + { + "schema": "WalletOut", + "field": "external_id", + "reason": "Caller-supplied external ID; not modeled on CustodialWallet.", + "owner": "eric@blindpay.com" + }, + { + "schema": "WalletTokenOut", + "field": "id", + "reason": "Not modeled on WalletTokenBalance (which models amount/token/address).", + "owner": "eric@blindpay.com" + }, + { + "schema": "WalletTokenOut", + "field": "symbol", + "reason": "WalletTokenBalance reads a `token` key instead of `symbol` for the currency code; worth confirming against a live response whether this is the intended wire key or a pre-existing key-name mismatch. Not touched in this PR.", + "owner": "eric@blindpay.com" + }, + { + "schema": "UpdateInstanceMemberIn", + "field": "user_role", + "reason": "UpdateMemberRoleInput has no toArray; Instances::updateMemberRole() builds `['user_role' => $input->role->value]` inline instead of calling a method on this DTO.", + "owner": "eric@blindpay.com" + }, + { + "schema": "UpdateInstanceIn", + "field": "compliance_emails", + "reason": "Instance-level notification routing setting added after UpdateInstanceInput was last generated; not modeled.", + "owner": "eric@blindpay.com" + }, + { + "schema": "UpdateInstanceIn", + "field": "customer_rfi_emails_enabled", + "reason": "Instance-level notification routing setting added after UpdateInstanceInput was last generated; not modeled.", + "owner": "eric@blindpay.com" + }, + { + "schema": "GetFeesOut", + "field": "id", + "reason": "Fee-config envelope metadata; FeesResponse only models the per-rail fee breakdown, not the record's own id.", + "owner": "eric@blindpay.com" + }, + { + "schema": "GetFeesOut", + "field": "instance_id", + "reason": "Fee-config envelope metadata; FeesResponse only models the per-rail fee breakdown, not instance_id.", + "owner": "eric@blindpay.com" + }, + { + "schema": "GetFeesOut", + "field": "created_at", + "reason": "Fee-config envelope metadata; FeesResponse only models the per-rail fee breakdown, not timestamps.", + "owner": "eric@blindpay.com" + }, + { + "schema": "GetFeesOut", + "field": "updated_at", + "reason": "Fee-config envelope metadata; FeesResponse only models the per-rail fee breakdown, not timestamps.", + "owner": "eric@blindpay.com" + }, + { + "schema": "GetFeesOut", + "field": "sepa", + "reason": "SEPA rail fee bucket added to the spec after FeesResponse was last generated; FeesResponse enumerates the other rails' FeeOptions but has no `sepa` property.", + "owner": "eric@blindpay.com" + }, + { + "schema": "AvailableBankDetails", + "field": "requiredWhen", + "reason": "Conditional-requirement expression added to the spec after BankDetail was last generated; not modeled.", + "owner": "eric@blindpay.com" + } + ] +} diff --git a/.github/workflows/api-sync-merged.yml b/.github/workflows/api-sync-merged.yml index 9ec73b6..46e32d3 100644 --- a/.github/workflows/api-sync-merged.yml +++ b/.github/workflows/api-sync-merged.yml @@ -37,3 +37,68 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git commit -m "chore: clear baseline — SDK synced" --allow-empty git push -f origin api-sync-data + + auto-tag-release: + name: Auto-tag release + if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'api-sync') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Read the VERSION const src/BlindPay.php was merged with + id: version + run: | + VERSION=$(php -r ' + $source = file_get_contents("src/BlindPay.php"); + if (! preg_match("/private const VERSION = .([0-9]+\.[0-9]+\.[0-9]+)./", $source, $m)) { + fwrite(STDERR, "could not find VERSION const in src/BlindPay.php\n"); + exit(1); + } + echo $m[1]; + ') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: "Guard: did this merge actually change VERSION?" + id: guard + run: | + # The api-sync PR is squash-merged onto main by api-sync.yml, so this merge is exactly + # one commit; its parent is main's previous tip, before the version bump. + BEFORE=$(git show HEAD~1:src/BlindPay.php 2>/dev/null | grep -oE "VERSION = '[0-9.]+'" || echo "none") + AFTER=$(grep -oE "VERSION = '[0-9.]+'" src/BlindPay.php) + echo "before=$BEFORE after=$AFTER" + if [ "$BEFORE" = "$AFTER" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: "Guard: does the tag already exist?" + id: tagcheck + run: | + git fetch origin --tags --quiet + if git rev-parse "refs/tags/v${{ steps.version.outputs.version }}" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Tag and push + if: steps.guard.outputs.changed == 'true' && steps.tagcheck.outputs.exists == 'false' + env: + GH_TOKEN: ${{ secrets.SDK_SYNC_PAT }} + run: | + git remote set-url origin "https://x-access-token:${{ secrets.SDK_SYNC_PAT }}@github.com/${{ github.repository }}.git" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "v${{ steps.version.outputs.version }}" + git push origin "v${{ steps.version.outputs.version }}" + + - name: Skip (no-op) reason + if: steps.guard.outputs.changed == 'false' || steps.tagcheck.outputs.exists == 'true' + run: | + echo "Not tagging: VERSION changed on this merge = ${{ steps.guard.outputs.changed }}, tag v${{ steps.version.outputs.version }} already exists = ${{ steps.tagcheck.outputs.exists }}" diff --git a/.github/workflows/api-sync.yml b/.github/workflows/api-sync.yml index 96470d6..fdb501c 100644 --- a/.github/workflows/api-sync.yml +++ b/.github/workflows/api-sync.yml @@ -15,30 +15,21 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Fetch API sync data - run: | - mkdir -p /tmp/api-sync - git fetch origin api-sync-data - git show origin/api-sync-data:.api-sync/changelog.md > /tmp/api-sync/changelog.md - echo "=== Changelog ===" - cat /tmp/api-sync/changelog.md + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none - - name: Check for existing api-sync PR - id: check-pr - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Fetch the new spec from api-sync-data run: | - PR_NUMBER=$(gh pr list --head api-sync --json number --jq '.[0].number // empty') - if [ -n "$PR_NUMBER" ]; then - echo "existing_pr=$PR_NUMBER" >> $GITHUB_OUTPUT - echo "Found existing api-sync PR: #$PR_NUMBER" - else - echo "existing_pr=" >> $GITHUB_OUTPUT - echo "No existing api-sync PR found" - fi + git fetch origin api-sync-data + git show origin/api-sync-data:.api-sync/spec-current.json > .api-sync/spec-current.json - - name: Create or checkout api-sync branch + - name: Create or reset the api-sync branch run: | git fetch origin api-sync 2>/dev/null || true if git rev-parse --verify origin/api-sync >/dev/null 2>&1; then @@ -48,63 +39,78 @@ jobs: git checkout -b api-sync fi - - name: Apply changes with Claude Code - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - claude_args: '--model claude-opus-5 --allowedTools "Bash(*),Read,Edit,Write,Glob,Grep"' - prompt: | - You are updating this SDK to match API changes. Read CLAUDE.md for this SDK's conventions. - - The changelog is at /tmp/api-sync/changelog.md — it contains ALL the information you need. - Do NOT read openapi.json. The changelog is self-contained. - - INSTRUCTIONS: - 1. Read CLAUDE.md thoroughly for this SDK's patterns and conventions. - 2. Read /tmp/api-sync/changelog.md — it lists every enum type with every value, every field to add, and every change to make. - 3. Implement ALL changes. Go through the changelog section by section: - - Section 1 (Enum Types): For each enum listed, check if the SDK has it. If missing, create it. If it exists, add any missing values. The changelog lists ALL values — add the ones that don't exist yet. - - Section 2 (Receiver Fields): Add each listed field to the receiver input/output types. - - Section 3 (Version): Minor bump. - 4. Do not skip ANY enum or field. Every item in the changelog must be addressed. - 5. Follow CLAUDE.md patterns exactly for naming, typing, and file organization. - 6. MINOR version bump only. - 7. Run lint and type check commands (see CLAUDE.md). Fix any errors until clean. - 8. Do NOT create commits — just modify the files. + - name: Run the patcher + id: patch + run: | + set +e + php scripts/api-sync.php --apply --report=/tmp/api-sync-report.json + echo "exit_code=$?" >> "$GITHUB_OUTPUT" + set -e + + - name: Fail loudly on needs-human + if: steps.patch.outputs.exit_code != '0' + run: | + echo "::error::api-sync found drift it cannot apply mechanically -- a human must decide. See the patcher output above, and update .api-sync/spec-map.json, .api-sync/unmodeled.json or .api-sync/known-divergences.json accordingly." + cat /tmp/api-sync-report.json || true + exit 1 + + - name: Check for changes + id: changes + run: | + if [ -n "$(git status --porcelain -- . ':!.github')" ]; then + echo "has_changes=true" >> "$GITHUB_OUTPUT" + else + echo "has_changes=false" >> "$GITHUB_OUTPUT" + fi + + - name: Exit quietly, nothing to sync + if: steps.changes.outputs.has_changes == 'false' + run: echo "No pending drift. Nothing to sync." + + - name: Determine version bump + if: steps.changes.outputs.has_changes == 'true' + id: bump + run: | + BUMP=$(php -r '$r = json_decode(file_get_contents("/tmp/api-sync-report.json"), true); echo $r["bump"]["type"] ?? "patch";') + VERSION=$(php -r '$r = json_decode(file_get_contents("/tmp/api-sync-report.json"), true); echo $r["bump"]["version"] ?? "";') + echo "type=$BUMP" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Commit and push - id: commit + if: steps.changes.outputs.has_changes == 'true' run: | git remote set-url origin "https://x-access-token:${{ secrets.SDK_SYNC_PAT }}@github.com/${{ github.repository }}.git" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" git checkout -- .github/workflows/ 2>/dev/null || true git add -A git reset HEAD .github/workflows/ 2>/dev/null || true - if git diff --staged --quiet; then - echo "No changes to commit" - echo "has_changes=false" >> $GITHUB_OUTPUT - exit 0 - fi - echo "has_changes=true" >> $GITHUB_OUTPUT - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git commit -m "feat: sync SDK with API changes" + git commit -m "${{ steps.bump.outputs.type == 'minor' && 'feat' || 'fix' }}: sync SDK with API changes (${{ steps.bump.outputs.type }}, v${{ steps.bump.outputs.version }})" git push --force-with-lease origin api-sync - name: Create or update PR - if: steps.commit.outputs.has_changes == 'true' + if: steps.changes.outputs.has_changes == 'true' env: GH_TOKEN: ${{ secrets.SDK_SYNC_PAT }} run: | - EXISTING_PR="${{ steps.check-pr.outputs.existing_pr }}" + PR_NUMBER=$(gh pr list --head api-sync --json number --jq '.[0].number // empty') + BODY="Automated deterministic sync from the latest API spec. + + Version bump: **${{ steps.bump.outputs.type }}** -> \`${{ steps.bump.outputs.version }}\` - if [ -n "$EXISTING_PR" ]; then - echo "Updating existing PR #$EXISTING_PR" - gh pr comment "$EXISTING_PR" --body "Updated with latest API changes." + Generated by \`php scripts/api-sync.php --apply\`. See the workflow run for the full report." + + if [ -n "$PR_NUMBER" ]; then + echo "Updating existing PR #$PR_NUMBER" + gh pr edit "$PR_NUMBER" --body "$BODY" else gh pr create \ - --title "feat: sync SDK with API changes" \ - --body "Automated SDK update from API changes." \ + --title "sync: SDK with API changes" \ + --body "$BODY" \ --base main \ --head api-sync \ --label api-sync + PR_NUMBER=$(gh pr list --head api-sync --json number --jq '.[0].number') fi + + gh pr merge "$PR_NUMBER" --auto --squash diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 119754e..1a7a2e2 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -27,6 +27,66 @@ jobs: coverage: none - name: Verify SDK wire keys against the spec snapshot run: php scripts/contract-check.php + api-sync-check: + name: API sync check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + - name: State reconciliation (spec-map.json + unmodeled.json + known-divergences.json vs spec-snapshot.json) + # Self-consistency gate: checks the committed code against the committed baseline, which is + # always present. .api-sync/spec-current.json is deliberately NOT committed -- it only + # exists transiently inside the api-sync.yml workflow run, fetched from api-sync-data. + run: php scripts/api-sync.php --check --spec=.api-sync/spec-snapshot.json + - name: Map validity (every spec-map.json entry resolves) + run: | + php -r ' + define("API_SYNC_LIB_ONLY", true); + require "scripts/api-sync.php"; + $map = loadJson(".api-sync/spec-map.json"); + $classIndex = scanAllClasses(getcwd()); + $errors = validateMap($map, $classIndex, getcwd()); + if (! empty($errors)) { + fwrite(STDERR, "Map validity FAILED:\n"); + foreach ($errors as $e) { + fwrite(STDERR, " - {$e}\n"); + } + exit(1); + } + fwrite(STDOUT, "Map validity OK: every spec-map.json entry resolves.\n"); + ' + - name: Determinism proof (apply into two scratch copies, diff byte-identical) + run: | + set -e + rm -rf /tmp/api-sync-determinism + mkdir -p /tmp/api-sync-determinism/a /tmp/api-sync-determinism/b + cp -R . /tmp/api-sync-determinism/a + cp -R . /tmp/api-sync-determinism/b + # Reports are written OUTSIDE the two trees being compared: --report's resolved path is + # an absolute, invocation-specific diagnostic (which directory you happened to run in), + # not part of the determinism guarantee, which is about the applied CODE being identical. + (cd /tmp/api-sync-determinism/a && php scripts/api-sync.php --apply --spec=.api-sync/spec-snapshot.json --report=/tmp/api-sync-determinism/report-a.json) + (cd /tmp/api-sync-determinism/b && php scripts/api-sync.php --apply --spec=.api-sync/spec-snapshot.json --report=/tmp/api-sync-determinism/report-b.json) + diff -rq --exclude=.git /tmp/api-sync-determinism/a /tmp/api-sync-determinism/b + - name: Coverage report (non-blocking -- spec operations/schemas with no SDK mapping) + if: always() + run: | + php scripts/api-sync.php --check --spec=.api-sync/spec-snapshot.json --report=/tmp/api-sync-report.json || true + php -r ' + $r = json_decode(file_get_contents("/tmp/api-sync-report.json"), true); + $gaps = $r["coverageGaps"] ?? []; + echo "Reachable spec schemas with no spec-map.json mapping (non-blocking; expected: the Rfi family and upload/analyze):\n"; + foreach ($gaps as $g) { + echo " - {$g}\n"; + } + echo count($gaps)." total.\n"; + ' + - name: Type audit (non-blocking -- full state comparison, surfaces pre-existing type debt too, not just new drift) + if: always() + run: php scripts/api-sync.php --audit-types --spec=.api-sync/spec-snapshot.json snyk: uses: ./.github/workflows/snyk.yaml needs: [lint, tests] diff --git a/.gitignore b/.gitignore index 0be9515..9a8ac0e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,7 @@ /.php-cs-fixer.cache /phpunit.xml /.env -*.log \ No newline at end of file +*.log + +# Delivered fresh by api-sync.yml from the api-sync-data branch on each dispatch; never committed. +/.api-sync/spec-current.json \ No newline at end of file diff --git a/composer.json b/composer.json index e5f6a26..cea0cbe 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,8 @@ "test:coverage": "pest --coverage", "lint:check": "pint --test", "lint:fix": "pint", - "contract-check": "php scripts/contract-check.php" + "contract-check": "php scripts/contract-check.php", + "api-sync": "php scripts/api-sync.php --apply", + "api-sync:check": "php scripts/api-sync.php --check" } } diff --git a/scripts/api-sync.php b/scripts/api-sync.php new file mode 100644 index 0000000..bca729b --- /dev/null +++ b/scripts/api-sync.php @@ -0,0 +1,1546 @@ + SDK sync patcher. + * + * State-based reconciliation (primary): for every (spec schema[/path] -> SDK class) pair in + * .api-sync/spec-map.json, every spec property must be present in the SDK class, or be listed + * in .api-sync/unmodeled.json. For every mapped enum, every spec member value must be present as + * an SDK case value, or be listed in .api-sync/known-divergences.json. Anything missing is + * pending drift, regardless of when it appeared. + * + * Old-vs-new spec diff (secondary): only used for removal detection (always a hard fail) and + * version-bump classification. + * + * Usage: + * php scripts/api-sync.php [--check] [--apply] [--audit-types] [--spec=path] [--report=path] + * Default mode is --check. Default --spec is .api-sync/spec-current.json. + * --audit-types is a separate, always-exit-0 mode: it prints every already-modeled property + * whose CURRENT spec type disagrees with its SDK type (state, not just forward drift), so + * pre-existing type debt stays visible without blocking CI. See auditTypes() for why this is + * broader than the blocking check. + * No Composer dependencies -- uses only the Tokenizer/JSON extensions PHP ships with. + */ + +declare(strict_types=1); + +$root = dirname(__DIR__); + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function parseArgs(array $argv): array +{ + $opts = ['apply' => false, 'check' => false, 'auditTypes' => false, 'spec' => null, 'report' => null]; + foreach (array_slice($argv, 1) as $arg) { + if ($arg === '--apply') { + $opts['apply'] = true; + } elseif ($arg === '--check') { + $opts['check'] = true; + } elseif ($arg === '--audit-types') { + $opts['auditTypes'] = true; + } elseif (str_starts_with($arg, '--spec=')) { + $opts['spec'] = substr($arg, strlen('--spec=')); + } elseif (str_starts_with($arg, '--report=')) { + $opts['report'] = substr($arg, strlen('--report=')); + } else { + fwrite(STDERR, "[api-sync] unknown argument: {$arg}\n"); + exit(1); + } + } + + return $opts; +} + +// --------------------------------------------------------------------------- +// Path validation -- every filesystem path this program touches that originates from a CLI +// argument or a config file on disk is resolved and validated here, once, right where it enters +// the program, rather than trusted implicitly at each later read/write call site. +// --------------------------------------------------------------------------- + +/** + * Canonicalize and validate a path this program is about to READ (--spec, and any file the + * tokenizer scans). Rejects NUL-byte injection and resolves `..`/symlinks via realpath() so every + * later use operates on one already-validated canonical path instead of re-trusting a raw string. + */ +function resolveReadablePath(string $path, string $argName): string +{ + if ($path === '' || str_contains($path, "\0")) { + fwrite(STDERR, "[api-sync] FAIL: invalid {$argName} path\n"); + exit(1); + } + $real = realpath($path); + if ($real === false || ! is_file($real)) { + fwrite(STDERR, "[api-sync] FAIL: {$argName} does not exist or is not a file: {$path}\n"); + exit(1); + } + + return $real; +} + +/** + * Canonicalize and validate a path this program is about to WRITE (--report). The parent + * directory must already exist -- this program never creates directories -- and resolving it via + * realpath() collapses `..`/symlinks so the eventual file_put_contents() target is unambiguous. + * Deliberately does NOT restrict the result to the repository root: --report is designed to write + * outside it (CI writes to /tmp; the determinism proof writes into scratch copies elsewhere on + * disk) -- that is required functionality, not a path-traversal vulnerability, since the value + * comes from a trusted CI workflow or an operator's own CLI invocation, never from request input. + */ +function resolveWritablePath(string $path, string $argName): string +{ + if ($path === '' || str_contains($path, "\0")) { + fwrite(STDERR, "[api-sync] FAIL: invalid {$argName} path\n"); + exit(1); + } + $dir = realpath(dirname($path)); + if ($dir === false || ! is_dir($dir)) { + fwrite(STDERR, "[api-sync] FAIL: directory for {$argName} does not exist: {$path}\n"); + exit(1); + } + + return $dir.DIRECTORY_SEPARATOR.basename($path); +} + +/** + * Resolve a file path declared in spec-map.json (or the hardcoded VERSION file) against $root, + * and refuse to touch anything outside it. spec-map.json is a committed, human-reviewed config + * file, not runtime request input, but every path built from it is still contained here: a + * corrupted or malicious entry (e.g. a `..` sequence) must never let --apply write outside the + * repository it was invoked on. + */ +function resolveWithinRoot(string $root, string $relativeFile, string $context): string +{ + $realRoot = realpath($root); + $candidate = rtrim($root, '/').'/'.$relativeFile; + $realDir = realpath(dirname($candidate)); + if ($realRoot === false || $realDir === false || ! str_starts_with($realDir.'/', $realRoot.'/')) { + fwrite(STDERR, "[api-sync] FAIL: {$context} resolves outside the repository root: {$relativeFile}\n"); + exit(1); + } + + return $realDir.'/'.basename($candidate); +} + +// --------------------------------------------------------------------------- +// JSON / spec helpers +// --------------------------------------------------------------------------- + +function loadJson(string $path): array +{ + if (! is_file($path)) { + fwrite(STDERR, "[api-sync] FAIL: file not found: {$path}\n"); + exit(1); + } + + return json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); +} + +/** Recursively collect every `#/components/schemas/X` ref target under $node. */ +function collectRefs(mixed $node, array &$out): void +{ + if (is_array($node)) { + if (isset($node['$ref']) && is_string($node['$ref']) && str_starts_with($node['$ref'], '#/components/schemas/')) { + $out[substr($node['$ref'], strlen('#/components/schemas/'))] = true; + } + foreach ($node as $v) { + collectRefs($v, $out); + } + } +} + +/** BFS transitive closure of schema names reachable from paths + webhooks. */ +function computeReachable(array $spec): array +{ + $schemas = $spec['components']['schemas'] ?? []; + $roots = []; + collectRefs($spec['paths'] ?? [], $roots); + collectRefs($spec['webhooks'] ?? [], $roots); + + $visited = $roots; + $queue = array_keys($roots); + while (! empty($queue)) { + $name = array_pop($queue); + if (! isset($schemas[$name])) { + continue; + } + $refs = []; + collectRefs($schemas[$name], $refs); + foreach ($refs as $r => $_) { + if (! isset($visited[$r])) { + $visited[$r] = true; + $queue[] = $r; + } + } + } + + return $visited; // name => true +} + +/** Top-level property names of a component schema. */ +function schemaProps(array $spec, string $schema): ?array +{ + $s = $spec['components']['schemas'][$schema] ?? null; + if ($s === null || ! isset($s['properties'])) { + return null; + } + + return array_keys($s['properties']); +} + +/** Enum values for `schema.property`, where property may itself be a nested inline object (dot path). */ +function specEnumValues(array $spec, string $schema, string $propertyPath): ?array +{ + $node = $spec['components']['schemas'][$schema] ?? null; + if ($node === null) { + return null; + } + foreach (explode('.', $propertyPath) as $part) { + $node = $node['properties'][$part] ?? null; + if ($node === null) { + return null; + } + } + + return $node['enum'] ?? null; +} + +/** Enum values for an inline (non-$ref) request-body property, addressed by "METHOD /path". */ +function operationEnumValues(array $spec, string $operation, string $property): ?array +{ + // $urlPath is an OpenAPI paths-map KEY (e.g. "/v1/instances/{instance_id}/quotes/fx"), never a + // filesystem path -- named distinctly from every filesystem $path in this file. + [$method, $urlPath] = explode(' ', $operation, 2); + $method = strtolower($method); + $node = $spec['paths'][$urlPath][$method]['requestBody']['content']['application/json']['schema']['properties'][$property] ?? null; + + return $node['enum'] ?? null; +} + +/** + * Nested inline object property names, e.g. schemaProps but for a dotted nested path. + * $schemaPath is a dotted property path WITHIN a JSON schema (e.g. "tracking_payment"), never a + * filesystem path -- named distinctly from every filesystem $path in this file so a static + * analyzer's data-flow tracing has no name-based reason to conflate the two. + */ +function nestedProps(array $spec, string $schema, string $schemaPath): ?array +{ + $node = $spec['components']['schemas'][$schema] ?? null; + if ($node === null) { + return null; + } + foreach (explode('.', $schemaPath) as $part) { + $node = $node['properties'][$part] ?? null; + if ($node === null) { + return null; + } + } + + return isset($node['properties']) ? array_keys($node['properties']) : null; +} + +// --------------------------------------------------------------------------- +// SDK source scanning (tokenizer, mirrors scripts/contract-check.php's style) +// --------------------------------------------------------------------------- + +function phpFilesUnder(string $dir): array +{ + $files = []; + if (! is_dir($dir)) { + return $files; + } + $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)); + foreach ($it as $file) { + if ($file->getExtension() === 'php') { + $files[] = $file->getPathname(); + } + } + sort($files); + + return $files; +} + +/** + * Scan one PHP file and return per-class info: + * isEnum, enumCases: [{name, value, line}], ctor: {startLine?, closeParenLine?, lastParamLine?, params:[{name,line}]}, + * fromArray: {startLine?, closeParenLine?, lastArgLine?, keys:[...]}, + * toArray: {style: 'none'|'literal'|'conditional', literalCloseLine?, literalLastLine?, + * conditionalReturnLine?, conditionalLastIfLine?, keys:[...]} + */ +function scanClassesDetailed(string $path): array +{ + $source = file_get_contents($path); + $tokens = token_get_all($source); + $tokens = array_values(array_filter($tokens, function ($t) { + if (! is_array($t)) { + return true; + } + + return ! in_array($t[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true); + })); + + $classes = []; + $braceDepth = 0; + $classStack = []; // [depth, name] + $functionStack = []; // [depth, name] + $pendingClassName = null; + $pendingIsEnum = false; + $pendingFunctionName = null; + $awaitingCtorParen = false; + $inCtorParams = false; + $ctorParenDepth = 0; + + $count = count($tokens); + $line = 1; + + for ($i = 0; $i < $count; $i++) { + $t = $tokens[$i]; + $id = is_array($t) ? $t[0] : null; + $text = is_array($t) ? $t[1] : $t; + $tokenLine = is_array($t) ? $t[2] : $line; + $line = $tokenLine; + + if (is_array($t) && in_array($id, [T_CLASS, T_ENUM, T_INTERFACE, T_TRAIT], true)) { + $pendingIsEnum = ($id === T_ENUM); + for ($j = $i + 1; $j < $count; $j++) { + $nt = $tokens[$j]; + if (is_array($nt) && $nt[0] === T_STRING) { + $pendingClassName = $nt[1]; + + break; + } + if (! is_array($nt) && $nt === '{') { + break; + } + } + } + + if (is_array($t) && $id === T_FUNCTION) { + for ($j = $i + 1; $j < $count; $j++) { + $nt = $tokens[$j]; + if (is_array($nt) && $nt[0] === T_STRING) { + $pendingFunctionName = $nt[1]; + if ($nt[1] === '__construct') { + $awaitingCtorParen = true; + } + + break; + } + if (! is_array($nt) && $nt === '{') { + break; + } + } + } + + $currentClass = end($classStack)[1] ?? null; + $currentFunction = end($functionStack)[1] ?? null; + + // Promoted constructor properties live in the parameter list, which closes BEFORE the + // function body opens -- track it via paren depth instead of the function-body stack. + if (! is_array($t) && $text === '(' && $awaitingCtorParen && ! $inCtorParams) { + $inCtorParams = true; + $ctorParenDepth = 1; + $awaitingCtorParen = false; + if ($currentClass !== null && isset($classes[$currentClass])) { + $classes[$currentClass]['ctor'] ??= ['params' => []]; + } + + continue; + } + if ($inCtorParams) { + if (! is_array($t) && $text === '(') { + $ctorParenDepth++; + } elseif (! is_array($t) && $text === ')') { + $ctorParenDepth--; + if ($ctorParenDepth === 0) { + $inCtorParams = false; + } + } elseif ($ctorParenDepth === 1 && is_array($t) && in_array($id, [T_PUBLIC, T_PROTECTED, T_PRIVATE], true) + && $currentClass !== null && isset($classes[$currentClass])) { + $typeParts = []; + for ($j = $i + 1; $j < $count; $j++) { + $nt = $tokens[$j]; + if (! is_array($nt) && in_array($nt, [',', ')'], true)) { + break; + } + if (is_array($nt) && $nt[0] === T_VARIABLE) { + $classes[$currentClass]['ctor']['params'][] = [ + 'name' => ltrim($nt[1], '$'), + 'line' => $nt[2], + 'type' => implode('', $typeParts), + ]; + + break; + } + if (is_array($nt) && $nt[0] === T_READONLY) { + continue; + } + // Type declaration tokens: `?`, a name (possibly namespaced with `\`), `|` for + // unions, or `array` -- which tokenizes as the dedicated T_ARRAY, not T_STRING. + if ((! is_array($nt) && in_array($nt, ['?', '\\', '|'], true)) + || (is_array($nt) && in_array($nt[0], [T_STRING, T_ARRAY], true))) { + $typeParts[] = is_array($nt) ? $nt[1] : $nt; + } + } + } + } + + if (! is_array($t) && $text === '{') { + $braceDepth++; + if ($pendingClassName !== null) { + $classStack[] = [$braceDepth, $pendingClassName]; + if (! isset($classes[$pendingClassName])) { + $classes[$pendingClassName] = [ + 'isEnum' => $pendingIsEnum, + 'enumCases' => [], + 'ctor' => null, + 'fromArray' => null, + 'toArray' => ['style' => 'none', 'keys' => []], + ]; + } + $pendingClassName = null; + $pendingIsEnum = false; + } elseif ($pendingFunctionName !== null) { + $functionStack[] = [$braceDepth, $pendingFunctionName]; + // record function open info + if ($currentClass !== null && isset($classes[$currentClass])) { + if ($pendingFunctionName === '__construct') { + $classes[$currentClass]['ctor'] ??= ['params' => []]; + } elseif ($pendingFunctionName === 'fromArray') { + $classes[$currentClass]['fromArray'] ??= ['args' => []]; + } + } + $pendingFunctionName = null; + } + + continue; + } + + if (! is_array($t) && $text === '}') { + if (! empty($functionStack) && end($functionStack)[0] === $braceDepth) { + array_pop($functionStack); + } elseif (! empty($classStack) && end($classStack)[0] === $braceDepth) { + array_pop($classStack); + } + $braceDepth--; + + continue; + } + + // enum case NAME = 'value'; at line $line + // The case name is always the token immediately after `case` -- read it positionally + // rather than by token type, since PHP allows semi-reserved keywords (e.g. `AS`, `DO`) + // as enum case names, and those tokenize as their keyword type, not T_STRING. + if ($currentClass !== null && isset($classes[$currentClass]) && $classes[$currentClass]['isEnum'] + && is_array($t) && $id === T_CASE) { + $caseName = (isset($tokens[$i + 1]) && is_array($tokens[$i + 1])) ? $tokens[$i + 1][1] : null; + $caseValue = null; + $caseLine = $line; + for ($j = $i + 1; $j < $count && $tokens[$j] !== ';'; $j++) { + $nt = $tokens[$j]; + if (is_array($nt) && $nt[0] === T_CONSTANT_ENCAPSED_STRING) { + $caseValue = trim($nt[1], "'\""); + } + } + if ($caseName !== null) { + $classes[$currentClass]['enumCases'][] = ['name' => $caseName, 'value' => $caseValue, 'line' => $caseLine]; + } + + continue; + } + + // fromArray: $data['key'] read anywhere in the body + if ($currentFunction === 'fromArray' && $currentClass !== null && isset($classes[$currentClass])) { + if (is_array($t) && $id === T_VARIABLE + && isset($tokens[$i + 1], $tokens[$i + 2], $tokens[$i + 3]) + && $tokens[$i + 1] === '[' + && is_array($tokens[$i + 2]) && $tokens[$i + 2][0] === T_CONSTANT_ENCAPSED_STRING + && $tokens[$i + 3] === ']' + ) { + $key = trim($tokens[$i + 2][1], "'\""); + $classes[$currentClass]['fromArray']['keys'][$key] = true; + } + // named-argument line: `camelName: ` at top level of the `new self(` call -- record for anchor purposes + if (is_array($t) && $id === T_STRING + && isset($tokens[$i + 1]) && $tokens[$i + 1] === ':' + && (! isset($tokens[$i + 2]) || ! (is_array($tokens[$i + 2]) && $tokens[$i + 2][0] === T_DOUBLE_COLON)) + ) { + $classes[$currentClass]['fromArray']['args'][] = ['name' => $text, 'line' => $line]; + } + } + + // toArray: two possible styles. + if ($currentFunction === 'toArray' && $currentClass !== null && isset($classes[$currentClass])) { + $ta = &$classes[$currentClass]['toArray']; + + // style "literal": 'key' => value inside an array literal + if (is_array($t) && $id === T_CONSTANT_ENCAPSED_STRING + && isset($tokens[$i + 1]) && is_array($tokens[$i + 1]) && $tokens[$i + 1][0] === T_DOUBLE_ARROW + ) { + $key = trim($text, "'\""); + $ta['keys'][$key] = true; + if ($ta['style'] !== 'conditional') { + $ta['style'] = 'literal'; + } + $ta['literalLastLine'] = $line; + } + + // style "conditional": $data['key'] = value; (subscript assignment, not literal) + if (is_array($t) && $id === T_VARIABLE + && isset($tokens[$i + 1], $tokens[$i + 2], $tokens[$i + 3], $tokens[$i + 4]) + && $tokens[$i + 1] === '[' + && is_array($tokens[$i + 2]) && $tokens[$i + 2][0] === T_CONSTANT_ENCAPSED_STRING + && $tokens[$i + 3] === ']' + && $tokens[$i + 4] === '=' + ) { + $key = trim($tokens[$i + 2][1], "'\""); + $ta['keys'][$key] = true; + $ta['style'] = 'conditional'; + $ta['conditionalLastIfLine'] = $line; + } + + // track `return $data;` line for the conditional style anchor + if (is_array($t) && $id === T_RETURN) { + for ($j = $i + 1; $j < $count; $j++) { + $nt = $tokens[$j]; + if (! is_array($nt)) { + break; + } + if ($nt[0] === T_VARIABLE && $nt[1] === '$data') { + $ta['conditionalReturnLine'] = $line; + } + + break; + } + } + unset($ta); + } + } + + return $classes; +} + +/** name => {file, isEnum, enumCases, ctor, fromArray, toArray} across src/Resources and src/Types. */ +function scanAllClasses(string $root): array +{ + $out = []; + foreach (['src/Resources', 'src/Types'] as $dir) { + foreach (phpFilesUnder("{$root}/{$dir}") as $file) { + $classes = scanClassesDetailed($file); + foreach ($classes as $name => $info) { + $info['file'] = substr($file, strlen($root) + 1); + $out[$name] = $info; + } + } + } + + return $out; +} + +// --------------------------------------------------------------------------- +// JSON schema type -> PHP type +// --------------------------------------------------------------------------- + +function phpTypeFor(array $propSchema): string +{ + $type = $propSchema['type'] ?? 'string'; + $types = is_array($type) ? $type : [$type]; + $types = array_values(array_diff($types, ['null'])); + $t = $types[0] ?? 'string'; + + return match ($t) { + 'integer' => 'int', + 'number' => 'float', + 'boolean' => 'bool', + 'array' => 'array', + 'object' => 'array', + default => 'string', + }; +} + +function snakeToCamel(string $snake): string +{ + $parts = explode('_', $snake); + $first = array_shift($parts); + + return $first.implode('', array_map(fn ($p) => $p === '' ? '' : ucfirst($p), $parts)); +} + +// --------------------------------------------------------------------------- +// Type-mismatch detection: spec declared type vs SDK declared property type +// --------------------------------------------------------------------------- + +/** + * Normalize a spec property's JSON Schema type into {category, nullable}. + * category is one of: null (untyped in spec -- cannot compare), 'ambiguous' (more than one + * non-null JSON type -- always needs a human), 'enum' (has an `enum` list; may legitimately be + * modeled as a scalar OR a backed-enum/object in the SDK), or a plain JSON Schema type name + * (string/integer/number/boolean/array/object). + */ +function specTypeCategory(array $propSchema): array +{ + $type = $propSchema['type'] ?? null; + if ($type === null) { + return ['category' => null, 'nullable' => null]; + } + $types = is_array($type) ? $type : [$type]; + $nullable = in_array('null', $types, true); + $nonNull = array_values(array_diff($types, ['null'])); + if (count($nonNull) !== 1) { + return ['category' => 'ambiguous', 'nullable' => $nullable]; + } + $category = isset($propSchema['enum']) ? 'enum' : $nonNull[0]; + + return ['category' => $category, 'nullable' => $nullable]; +} + +/** + * Normalize a PHP promoted-property type declaration (e.g. "?string", "Network", "?array") + * into {category, nullable}. A bare class/enum name normalizes to 'object'. + */ +function phpTypeCategory(?string $phpType): array +{ + if ($phpType === null || $phpType === '') { + return ['category' => null, 'nullable' => null, 'className' => null]; + } + $nullable = str_starts_with($phpType, '?'); + $base = ltrim($phpType, '?'); + if (str_contains($base, '|')) { + return ['category' => 'ambiguous', 'nullable' => $nullable, 'className' => null]; + } + $scalarMap = ['string' => 'string', 'int' => 'integer', 'float' => 'number', 'bool' => 'boolean', 'array' => 'array']; + $category = $scalarMap[$base] ?? 'object'; + + return ['category' => $category, 'nullable' => $nullable, 'className' => $category === 'object' ? $base : null]; +} + +/** + * Whether a spec property's base representation and an SDK property's base representation are + * compatible enough that a real spec value is guaranteed to decode correctly. Nullability is + * deliberately NOT considered here -- see typeNullabilityWidened() below for why. Deliberately + * conservative on the base category: anything not positively known to be safe is a mismatch. + */ +function categoriesCompatible(array $spec, array $php): bool +{ + if ($spec['category'] === null || $php['category'] === null) { + return true; // one side can't be determined -- do not force a false positive + } + if ($spec['category'] === 'ambiguous' || $php['category'] === 'ambiguous') { + return false; + } + // This SDK's established decode pattern: a date-time string is parsed into DateTimeImmutable. + if ($spec['category'] === 'string' && ($php['className'] ?? null) === 'DateTimeImmutable') { + return true; + } + // PHP widens int to float safely, even under strict_types (a documented special case) -- the + // reverse (spec `number`, SDK `int`) is NOT safe, a fractional value would lose precision. + if ($spec['category'] === 'integer' && $php['category'] === 'number') { + return true; + } + if ($spec['category'] === 'enum') { + // May be modeled as a plain scalar (deliberately loose, e.g. known-divergences.json) or as + // a backed enum/class -- both parse every spec value without error. + return in_array($php['category'], ['string', 'object'], true); + } + // json_decode(..., true) throughout this codebase represents a JSON object as a PHP + // associative array, so a spec `object` modeled as PHP `array` is this SDK's norm, not a bug. + if ($spec['category'] === 'object' && $php['category'] === 'array') { + return true; + } + + return $spec['category'] === $php['category']; +} + +/** + * Whether nullability was newly ADDED to this property's spec type between $oldPropSchema and + * $newPropSchema, while the SDK's declared type is not itself nullable. Checked as an old-vs-new + * event, not a pure state check: this codebase's spec has long-standing, pervasive `|null` + * annotations on fields the SDK still declares non-nullable (predating every snapshot on record), + * and re-litigating that entire backlog on every run is neither this patcher's job nor safe to + * silently mass-fix. A genuinely NEW nullability relaxation is a real, actionable signal though. + */ +function typeNullabilityWidened(array $oldPropSchema, array $newPropSchema, array $phpType): bool +{ + if ($phpType['nullable'] ?? true) { + return false; // SDK already tolerates null -- nothing at risk + } + $old = specTypeCategory($oldPropSchema); + $new = specTypeCategory($newPropSchema); + + return ! $old['nullable'] && $new['nullable']; +} + +// --------------------------------------------------------------------------- +// Map validation +// --------------------------------------------------------------------------- + +function validateMap(array $map, array $classIndex, string $root): array +{ + $errors = []; + foreach ($map['types'] as $entry) { + foreach ($entry['sdk'] as $site) { + $class = $site['class']; + if (! isset($classIndex[$class])) { + $errors[] = "map anchor not found: class {$class} declared in spec-map.json types (file {$site['file']}) does not exist in src/"; + + continue; + } + $actualFile = $classIndex[$class]['file']; + if ($actualFile !== $site['file']) { + $errors[] = "map anchor mismatch: class {$class} is declared at {$actualFile}, spec-map.json says {$site['file']}"; + } + } + } + foreach ($map['enums'] as $entry) { + $class = $entry['sdk']['class']; + if (! isset($classIndex[$class]) || ! $classIndex[$class]['isEnum']) { + $errors[] = "map anchor not found: enum {$class} declared in spec-map.json enums does not exist (or is not an enum) in src/"; + } + } + + return $errors; +} + +// --------------------------------------------------------------------------- +// Reconciliation: enums +// --------------------------------------------------------------------------- + +function enumSpecValues(array $spec, array $entry): ?array +{ + $s = $entry['spec']; + if (isset($s['kind']) && $s['kind'] === 'webhookTopics') { + $topics = $spec['webhooks'] ?? []; + + return array_keys($topics); + } + if (isset($s['operation'])) { + return operationEnumValues($spec, $s['operation'], $s['property']); + } + + return specEnumValues($spec, $s['schema'], $s['property']); +} + +function reconcileEnums(array $map, array $newSpec, array $reachable, array $classIndex, array $divergenceEnumIndex): array +{ + $applicable = []; + $needsHuman = []; + + foreach ($map['enums'] as $entry) { + $s = $entry['spec']; + if (isset($s['schema']) && ! isset($reachable[$s['schema']])) { + continue; // schema no longer reachable -- skip by construction + } + + $specValues = enumSpecValues($newSpec, $entry); + if ($specValues === null) { + continue; // property/operation gone entirely; handled by structural diff + } + + $className = $entry['sdk']['class']; + $classInfo = $classIndex[$className] ?? null; + if ($classInfo === null) { + continue; // already reported by validateMap + } + $sdkValues = array_map(fn ($c) => $c['value'], $classInfo['enumCases']); + $sdkValueSet = array_flip($sdkValues); + + foreach ($specValues as $value) { + if (isset($sdkValueSet[$value])) { + continue; + } + if (isset($divergenceEnumIndex["{$className}|{$value}"])) { + continue; // recorded known divergence, not pending drift + } + $applicable[] = [ + 'kind' => 'enum-member-added', + 'enum' => $className, + 'file' => $classInfo['file'], + 'value' => $value, + 'sortKey' => "{$className}|{$value}", + ]; + } + } + + return [$applicable, $needsHuman]; +} + +// --------------------------------------------------------------------------- +// Reconciliation: types / fields +// --------------------------------------------------------------------------- + +function typeSpecSchemas(array $entry): array +{ + $spec = $entry['spec']; + + return is_array($spec) ? $spec : [$spec]; +} + +function reconcileTypes(array $map, array $newSpec, array $reachable, array $classIndex, array $unmodeledIndex, array $divergenceFieldIndex = []): array +{ + $applicable = []; + $needsHuman = []; + + foreach ($map['types'] as $entry) { + $schemas = typeSpecSchemas($entry); + // A dotted schema property path (e.g. "tracking_payment"), never a filesystem path. + $schemaPath = $entry['path'] ?? null; + // unmodeled.json records one entry per group (using the first schema as the canonical + // name), not once per schema in a multi-schema entry like ["PayoutOut","PayoutOnEvmOut"]. + $canonicalSchema = $schemas[0]; + + // camelCase param name -> PHP type string. Only meaningful for a 1:1 schema-to-class + // mapping: a discriminator fan-out (e.g. CreateBankAccountIn's 10 rails) has each class + // model only its own rail's requiredness for a shared wire key, which genuinely diverges + // from the flattened spec schema's nullability -- that's covered by field-presence + // checking only, not per-field type strictness. + $ctorTypesByParam = []; + if (count($entry['sdk']) === 1) { + $info = $classIndex[$entry['sdk'][0]['class']] ?? null; + foreach ($info['ctor']['params'] ?? [] as $param) { + $ctorTypesByParam[$param['name']] ??= $param['type']; + } + } + + foreach ($schemas as $schemaName) { + if (! isset($reachable[$schemaName])) { + continue; // no longer reachable -- skip by construction + } + + $specProps = $schemaPath !== null + ? nestedProps($newSpec, $schemaName, $schemaPath) + : schemaProps($newSpec, $schemaName); + + if ($specProps === null) { + continue; // schema/property gone; handled by structural diff + } + + // union of wire keys already modeled across all mapped SDK classes for this schema + $modeled = []; + foreach ($entry['sdk'] as $site) { + $info = $classIndex[$site['class']] ?? null; + if ($info === null) { + continue; + } + foreach (array_keys($info['fromArray']['keys'] ?? []) as $k) { + $modeled[$k] = true; + } + foreach (array_keys($info['toArray']['keys'] ?? []) as $k) { + $modeled[$k] = true; + } + } + + foreach ($specProps as $field) { + if (isset($modeled[$field])) { + // Already modeled -- check the declared type still matches, not just presence. + $divergenceKey = "{$canonicalSchema}|{$field}"; + if (isset($divergenceFieldIndex[$divergenceKey])) { + continue; + } + $camel = snakeToCamel($field); + $phpType = $ctorTypesByParam[$camel] ?? null; + if ($phpType === null) { + continue; // modeled via fromArray/toArray only (e.g. no promoted ctor prop) -- nothing to compare + } + $propSchema = $schemaPath !== null + ? nestedPropSchema($newSpec, $schemaName, $schemaPath, $field) + : ($newSpec['components']['schemas'][$schemaName]['properties'][$field] ?? []); + $specType = specTypeCategory($propSchema); + $sdkType = phpTypeCategory($phpType); + if (! categoriesCompatible($specType, $sdkType)) { + $label = $schemaName.($schemaPath !== null ? ".{$schemaPath}" : ''); + $needsHuman[] = "NEEDS_HUMAN: type mismatch on {$label}.{$field}: spec declares " + .($specType['category'] ?? 'unknown').($specType['nullable'] ? '|null' : '') + .", SDK declares {$phpType} (mapped class(es): ".implode(', ', array_column($entry['sdk'], 'class')).')'; + } + + continue; + } + $unmodeledKey = $canonicalSchema.'|'.($schemaPath ?? '').'|'.$field; + if (isset($unmodeledIndex[$unmodeledKey])) { + continue; + } + + // determine target class(es) to patch: prefer a class whose family (input vs response) + // is unambiguous; for a fan-out with multiple sdk sites we apply to ALL of them, since + // any of them might legitimately accept/return the new field. + foreach ($entry['sdk'] as $site) { + $applicable[] = [ + 'kind' => 'field-added', + 'schema' => $schemaName, + 'path' => $schemaPath, + 'field' => $field, + 'class' => $site['class'], + 'file' => $site['file'], + 'propSchema' => $schemaPath !== null + ? (nestedPropSchema($newSpec, $schemaName, $schemaPath, $field)) + : ($newSpec['components']['schemas'][$schemaName]['properties'][$field] ?? []), + 'sortKey' => "{$schemaName}|".($schemaPath ?? '')."|{$field}|{$site['class']}", + ]; + } + } + } + } + + return [$applicable, $needsHuman]; +} + +// $schemaPath is a dotted schema property path, never a filesystem path -- see nestedProps(). +function nestedPropSchema(array $spec, string $schema, string $schemaPath, string $field): array +{ + $node = $spec['components']['schemas'][$schema] ?? []; + foreach (explode('.', $schemaPath) as $part) { + $node = $node['properties'][$part] ?? []; + } + + return $node['properties'][$field] ?? []; +} + +// --------------------------------------------------------------------------- +// Structural diff (old vs new): removals + new-and-unmapped surface +// --------------------------------------------------------------------------- + +function pathMethodSet(array $spec): array +{ + $out = []; + foreach ($spec['paths'] ?? [] as $p => $methods) { + foreach ($methods as $m => $_) { + if (in_array($m, ['get', 'post', 'put', 'patch', 'delete'], true)) { + $out["{$m} {$p}"] = true; + } + } + } + + return $out; +} + +function computeStructuralDiff(array $oldSpec, array $newSpec, array $reachableOld, array $reachableNew, array $map, array $classIndex = []): array +{ + $issues = []; + + $oldOps = pathMethodSet($oldSpec); + $newOps = pathMethodSet($newSpec); + foreach (array_diff(array_keys($oldOps), array_keys($newOps)) as $op) { + $issues[] = "NEEDS_HUMAN: operation removed: {$op} (breaking, requires a deliberate major)"; + } + foreach (array_diff(array_keys($newOps), array_keys($oldOps)) as $op) { + $issues[] = "NEEDS_HUMAN: new operation: {$op} (needs naming/grouping decisions, cannot be auto-modeled)"; + } + + $oldSchemas = array_keys($oldSpec['components']['schemas'] ?? []); + $newSchemas = array_keys($newSpec['components']['schemas'] ?? []); + foreach (array_diff($oldSchemas, $newSchemas) as $s) { + if (isset($reachableOld[$s])) { + $issues[] = "NEEDS_HUMAN: schema removed: {$s} (breaking, requires a deliberate major)"; + } + } + + $ignored = []; + foreach ($map['ignore']['schemas'] ?? [] as $ig) { + $ignored[$ig['schema']] = true; + } + $mapped = []; + foreach ($map['types'] as $entry) { + foreach (typeSpecSchemas($entry) as $s) { + $mapped[$s] = true; + } + } + foreach ($map['enums'] as $entry) { + if (isset($entry['spec']['schema'])) { + $mapped[$entry['spec']['schema']] = true; + } + } + foreach (array_diff($newSchemas, $oldSchemas) as $s) { + if (isset($reachableNew[$s]) && ! isset($ignored[$s]) && ! isset($mapped[$s])) { + $issues[] = "NEEDS_HUMAN: new schema: {$s} (needs a mapping decision, cannot be auto-modeled)"; + } + } + + $oldWebhooks = array_keys($oldSpec['webhooks'] ?? []); + $newWebhooks = array_keys($newSpec['webhooks'] ?? []); + foreach (array_diff($oldWebhooks, $newWebhooks) as $w) { + $issues[] = "NEEDS_HUMAN: webhook topic removed: {$w} (breaking, requires a deliberate major)"; + } + + // removed enum values / removed or type-changed properties on mapped schemas + foreach ($map['enums'] as $entry) { + $s = $entry['spec']; + if (isset($s['schema']) && (! isset($reachableOld[$s['schema']]) || ! isset($reachableNew[$s['schema']]))) { + continue; + } + $oldValues = enumSpecValues($oldSpec, $entry); + $newValues = enumSpecValues($newSpec, $entry); + if ($oldValues === null || $newValues === null) { + continue; + } + $removed = array_diff($oldValues, $newValues); + if (! empty($removed)) { + $label = $entry['sdk']['class']; + $issues[] = 'NEEDS_HUMAN: enum value(s) removed from '.$label.': '.implode(', ', $removed).' (breaking, requires a deliberate major)'; + } + } + + foreach ($map['types'] as $entry) { + // A dotted schema property path (e.g. "tracking_payment"), never a filesystem path. + $schemaPath = $entry['path'] ?? null; + $schemas = typeSpecSchemas($entry); + // See reconcileTypes(): a discriminator fan-out's flattened schema nullability doesn't + // apply uniformly to every rail's class, so per-field type/nullability checks only make + // sense for an unambiguous 1:1 mapping. + $ctorTypesByParam = []; + if (count($entry['sdk']) === 1) { + $info = $classIndex[$entry['sdk'][0]['class']] ?? null; + foreach ($info['ctor']['params'] ?? [] as $param) { + $ctorTypesByParam[$param['name']] ??= $param['type']; + } + } + + foreach ($schemas as $schemaName) { + if (! isset($reachableOld[$schemaName]) || ! isset($reachableNew[$schemaName])) { + continue; + } + $oldProps = $schemaPath !== null ? nestedProps($oldSpec, $schemaName, $schemaPath) : schemaProps($oldSpec, $schemaName); + $newProps = $schemaPath !== null ? nestedProps($newSpec, $schemaName, $schemaPath) : schemaProps($newSpec, $schemaName); + if ($oldProps === null || $newProps === null) { + continue; + } + $removed = array_diff($oldProps, $newProps); + if (! empty($removed)) { + $label = $schemaName.($schemaPath !== null ? ".{$schemaPath}" : ''); + $issues[] = 'NEEDS_HUMAN: propert'.(count($removed) === 1 ? 'y' : 'ies')." removed from {$label}: ".implode(', ', $removed).' (breaking, requires a deliberate major)'; + } + + foreach (array_intersect($oldProps, $newProps) as $field) { + $phpType = $ctorTypesByParam[snakeToCamel($field)] ?? null; + if ($phpType === null) { + continue; + } + $oldPropSchema = $schemaPath !== null ? nestedPropSchema($oldSpec, $schemaName, $schemaPath, $field) : ($oldSpec['components']['schemas'][$schemaName]['properties'][$field] ?? []); + $newPropSchema = $schemaPath !== null ? nestedPropSchema($newSpec, $schemaName, $schemaPath, $field) : ($newSpec['components']['schemas'][$schemaName]['properties'][$field] ?? []); + if (typeNullabilityWidened($oldPropSchema, $newPropSchema, phpTypeCategory($phpType))) { + $label = $schemaName.($schemaPath !== null ? ".{$schemaPath}" : ''); + $issues[] = "NEEDS_HUMAN: {$label}.{$field} newly allows null in the spec, but the SDK declares a non-nullable {$phpType} (mapped class: {$entry['sdk'][0]['class']})"; + } + } + } + } + + sort($issues); + + return $issues; +} + +// --------------------------------------------------------------------------- +// Coverage report (non-blocking) +// --------------------------------------------------------------------------- + +function computeCoverageReport(array $map, array $spec, array $reachable): array +{ + $mapped = []; + foreach ($map['types'] as $entry) { + foreach (typeSpecSchemas($entry) as $s) { + $mapped[$s] = true; + } + } + $ignored = []; + foreach ($map['ignore']['schemas'] ?? [] as $ig) { + $ignored[$ig['schema']] = true; + } + + $gaps = []; + foreach (array_keys($reachable) as $schema) { + if (! isset($mapped[$schema]) && ! isset($ignored[$schema])) { + $gaps[] = $schema; + } + } + sort($gaps); + + return $gaps; +} + +// --------------------------------------------------------------------------- +// Type audit (non-blocking): full state comparison, not just forward drift +// --------------------------------------------------------------------------- + +/** + * Compares every already-modeled property's CURRENT spec type against its declared PHP type, + * for every mapped schema/path -- regardless of whether that mismatch is old or new. This is + * deliberately broader than the blocking --check type-mismatch gate (reconcileTypes) and than + * typeNullabilityWidened() (computeStructuralDiff), which only fire on genuinely NEW drift: this + * is the same state-vs-event distinction the whole design is built on, applied to types instead + * of presence. Never blocks: purely a printed report for a human to triage (Phase C). + * + * Discriminator fan-outs (more than one SDK class per entry) are reported as skipped, not + * silently omitted: a flattened spec schema's nullability/requiredness genuinely differs per + * rail, so a union-of-classes comparison would be noise, not signal. + */ +function auditTypes(array $map, array $spec, array $reachable, array $classIndex, array $divergenceFieldIndex): array +{ + $findings = []; + $skippedFanOuts = []; + + foreach ($map['types'] as $entry) { + $schemas = typeSpecSchemas($entry); + // A dotted schema property path (e.g. "tracking_payment"), never a filesystem path. + $schemaPath = $entry['path'] ?? null; + $canonicalSchema = $schemas[0]; + + if (count($entry['sdk']) > 1) { + $skippedFanOuts[] = $canonicalSchema.($schemaPath !== null ? ".{$schemaPath}" : ''); + + continue; + } + + $info = $classIndex[$entry['sdk'][0]['class']] ?? null; + $ctorTypesByParam = []; + foreach ($info['ctor']['params'] ?? [] as $param) { + $ctorTypesByParam[$param['name']] ??= $param['type']; + } + + foreach ($schemas as $schemaName) { + if (! isset($reachable[$schemaName])) { + continue; + } + $specProps = $schemaPath !== null ? nestedProps($spec, $schemaName, $schemaPath) : schemaProps($spec, $schemaName); + if ($specProps === null) { + continue; + } + + foreach ($specProps as $field) { + $camel = snakeToCamel($field); + $phpType = $ctorTypesByParam[$camel] ?? null; + if ($phpType === null) { + continue; // not modeled via a promoted constructor property -- nothing to compare + } + $propSchema = $schemaPath !== null + ? nestedPropSchema($spec, $schemaName, $schemaPath, $field) + : ($spec['components']['schemas'][$schemaName]['properties'][$field] ?? []); + $specType = specTypeCategory($propSchema); + $sdkType = phpTypeCategory($phpType); + + $categoryMismatch = ! categoriesCompatible($specType, $sdkType); + $nullabilityMismatch = $specType['nullable'] === true && $sdkType['nullable'] !== true; + if (! $categoryMismatch && ! $nullabilityMismatch) { + continue; + } + + $label = $schemaName.($schemaPath !== null ? ".{$schemaPath}" : ''); + $findings[] = [ + 'field' => "{$label}.{$field}", + 'class' => $entry['sdk'][0]['class'], + 'specType' => ($specType['category'] ?? 'unknown').($specType['nullable'] ? '|null' : ''), + 'sdkType' => $phpType, + 'categoryMismatch' => $categoryMismatch, + 'nullabilityMismatch' => $nullabilityMismatch, + 'recordedDivergence' => isset($divergenceFieldIndex["{$canonicalSchema}|{$field}"]), + ]; + } + } + } + + usort($findings, fn ($a, $b) => $a['field'] <=> $b['field']); + sort($skippedFanOuts); + + return ['findings' => $findings, 'skippedFanOuts' => $skippedFanOuts]; +} + +// --------------------------------------------------------------------------- +// Applying changes (surgical text splicing) +// --------------------------------------------------------------------------- + +function detectIndent(string $line): string +{ + preg_match('/^(\s*)/', $line, $m); + + return $m[1]; +} + +function applyEnumCaseInsertion(string $root, string $file, string $className, string $value, array $newSpec, array $specValuesByEnum): void +{ + $path = resolveWithinRoot($root, $file, "enum class file for {$className}"); + $lines = file($path, FILE_IGNORE_NEW_LINES); + $classes = scanClassesDetailed($path); + $info = $classes[$className]; + $cases = $info['enumCases']; + + $specOrder = $specValuesByEnum[$className] ?? null; + $anchorLine = null; + + if ($specOrder !== null) { + $idx = array_search($value, $specOrder, true); + if ($idx !== false) { + $sdkValueToLine = []; + foreach ($cases as $c) { + $sdkValueToLine[$c['value']] = $c['line']; + } + for ($j = $idx - 1; $j >= 0; $j--) { + if (isset($sdkValueToLine[$specOrder[$j]])) { + $anchorLine = $sdkValueToLine[$specOrder[$j]]; + + break; + } + } + } + } + + if ($anchorLine === null) { + // no earlier spec value already modeled -- insert as the first case, right after the enum's opening brace + $anchorLine = $cases[0]['line'] - 1; // insert before first case (i.e. after opening brace line) + $indent = detectIndent($lines[$cases[0]['line'] - 1]); + $caseName = deriveEnumCaseName($className, $value); + array_splice($lines, $anchorLine, 0, ["{$indent}case {$caseName} = '{$value}';"]); + } else { + $indent = detectIndent($lines[$anchorLine - 1]); + $caseName = deriveEnumCaseName($className, $value); + array_splice($lines, $anchorLine, 0, ["{$indent}case {$caseName} = '{$value}';"]); + } + + file_put_contents($path, implode("\n", $lines)."\n"); +} + +/** Derive an UPPER_SNAKE case name for a new enum value, following the file's own convention. */ +function deriveEnumCaseName(string $className, string $value): string +{ + if ($className === 'BusinessIndustry' && ctype_digit($value)) { + return "NAICS_{$value}"; + } + if ($className === 'EstimatedAnnualRevenue') { + return 'RANGE_'.strtoupper($value); + } + $name = strtoupper(preg_replace('/[^A-Za-z0-9]+/', '_', $value)); + if (preg_match('/^[0-9]/', $name)) { + $name = 'V_'.$name; + } + + return $name; +} + +/** + * Apply a 2-or-3-part field insertion to one class: constructor property (always), + * fromArray line (only if the class has fromArray), toArray line (only if the class has toArray, + * honoring its existing literal-vs-conditional style). + */ +function applyFieldInsertion(string $root, string $file, string $className, string $field, array $propSchema): void +{ + $path = resolveWithinRoot($root, $file, "class file for {$className}"); + $camel = snakeToCamel($field); + $phpType = phpTypeFor($propSchema); + + // Re-scan fresh each time so line numbers stay valid across successive edits to the same file. + $lines = file($path, FILE_IGNORE_NEW_LINES); + $classes = scanClassesDetailed($path); + $info = $classes[$className]; + + // 1) constructor promoted property -- always last param, so it is always syntactically safe. + // Preserve the file's own convention: if the previous last param had no trailing comma, the + // new (now-last) line gets none either, and the previous line gains one since it's no longer last. + if ($info['ctor'] !== null && ! empty($info['ctor']['params'])) { + $lastParam = end($info['ctor']['params']); + $lastLine = $lastParam['line']; + $indent = detectIndent($lines[$lastLine - 1]); + $hadTrailingComma = str_ends_with(rtrim($lines[$lastLine - 1]), ','); + if (! $hadTrailingComma) { + $lines[$lastLine - 1] = rtrim($lines[$lastLine - 1]).','; + } + $newLine = "{$indent}public ?{$phpType} \${$camel} = null".($hadTrailingComma ? ',' : ''); + array_splice($lines, $lastLine, 0, [$newLine]); + file_put_contents($path, implode("\n", $lines)."\n"); + $lines = file($path, FILE_IGNORE_NEW_LINES); + $classes = scanClassesDetailed($path); + $info = $classes[$className]; + } + + // 2) fromArray line -- only if the method exists. Same trailing-comma convention as above. + if ($info['fromArray'] !== null && ! empty($info['fromArray']['args'])) { + $lastArg = end($info['fromArray']['args']); + $lastLine = $lastArg['line']; + $indent = detectIndent($lines[$lastLine - 1]); + $hadTrailingComma = str_ends_with(rtrim($lines[$lastLine - 1]), ','); + if (! $hadTrailingComma) { + $lines[$lastLine - 1] = rtrim($lines[$lastLine - 1]).','; + } + $newLine = "{$indent}{$camel}: \$data['{$field}'] ?? null".($hadTrailingComma ? ',' : ''); + array_splice($lines, $lastLine, 0, [$newLine]); + file_put_contents($path, implode("\n", $lines)."\n"); + $lines = file($path, FILE_IGNORE_NEW_LINES); + $classes = scanClassesDetailed($path); + $info = $classes[$className]; + } + + // 3) toArray line -- only if the method exists, honoring the class's own style. + $ta = $info['toArray']; + if ($ta['style'] === 'literal' && isset($ta['literalLastLine'])) { + $lastLine = $ta['literalLastLine']; + $indent = detectIndent($lines[$lastLine - 1]); + if (! str_ends_with(rtrim($lines[$lastLine - 1]), ',')) { + $lines[$lastLine - 1] = rtrim($lines[$lastLine - 1]).','; + } + array_splice($lines, $lastLine, 0, ["{$indent}'{$field}' => \$this->{$camel},"]); + file_put_contents($path, implode("\n", $lines)."\n"); + } elseif ($ta['style'] === 'conditional' && isset($ta['conditionalReturnLine'])) { + $returnLine = $ta['conditionalReturnLine']; + $indent = detectIndent($lines[$returnLine - 1]); + $block = [ + '', + "{$indent}if (\$this->{$camel} !== null) {", + "{$indent} \$data['{$field}'] = \$this->{$camel};", + "{$indent}}", + ]; + array_splice($lines, $returnLine - 1, 0, $block); + file_put_contents($path, implode("\n", $lines)."\n"); + } +} + +// --------------------------------------------------------------------------- +// Version bump +// --------------------------------------------------------------------------- + +function computeBump(array $applicable): ?string +{ + $hasEnum = false; + $hasField = false; + foreach ($applicable as $c) { + if ($c['kind'] === 'enum-member-added') { + $hasEnum = true; + } + if ($c['kind'] === 'field-added') { + $hasField = true; + } + } + if ($hasEnum) { + return 'minor'; + } + if ($hasField) { + return 'patch'; + } + + return null; +} + +function bumpVersionString(string $version, string $bump): string +{ + [$major, $minor, $patch] = array_map('intval', explode('.', $version)); + if ($bump === 'minor') { + $minor++; + $patch = 0; + } elseif ($bump === 'patch') { + $patch++; + } + + return "{$major}.{$minor}.{$patch}"; +} + +function bumpVersion(string $root, string $bump): string +{ + $file = resolveWithinRoot($root, 'src/BlindPay.php', 'VERSION file'); + $source = file_get_contents($file); + if (! preg_match("/private const VERSION = '([0-9]+\\.[0-9]+\\.[0-9]+)';/", $source, $m)) { + fwrite(STDERR, "[api-sync] FAIL: could not find VERSION const in src/BlindPay.php\n"); + exit(1); + } + $new = bumpVersionString($m[1], $bump); + $source = str_replace("private const VERSION = '{$m[1]}';", "private const VERSION = '{$new}';", $source); + file_put_contents($file, $source); + + return $new; +} + +// --------------------------------------------------------------------------- +// Determinism helpers +// --------------------------------------------------------------------------- + +function encodeJsonDeterministic(array $data): string +{ + return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n"; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +/** + * Entry point, extracted into a function so tests can `define('API_SYNC_LIB_ONLY', true)` + * and require this file to get the helpers above without running the CLI. + */ +function runCli(array $argv, string $root): void +{ + $opts = parseArgs($argv); + $mode = $opts['apply'] ? 'apply' : 'check'; + // Resolved and validated once, right where they enter the program (see resolveReadablePath()/ + // resolveWritablePath() above), not re-trusted as raw strings at each later read/write. + $specPath = resolveReadablePath($opts['spec'] ?? ($root.'/.api-sync/spec-current.json'), '--spec'); + $reportPath = $opts['report'] !== null ? resolveWritablePath($opts['report'], '--report') : null; + + $map = loadJson($root.'/.api-sync/spec-map.json'); + $unmodeled = loadJson($root.'/.api-sync/unmodeled.json'); + $divergences = loadJson($root.'/.api-sync/known-divergences.json'); + $oldSpec = loadJson($root.'/.api-sync/spec-snapshot.json'); + $newSpec = loadJson($specPath); + + $unmodeledIndex = []; + foreach ($unmodeled['entries'] as $e) { + $key = ($e['schema'] ?? '').'|'.($e['path'] ?? '').'|'.($e['field'] ?? ''); + $unmodeledIndex[$key] = true; + } + + $divergenceEnumIndex = []; + foreach ($divergences['enumValues'] as $d) { + if ($d['specValue'] !== null) { + $divergenceEnumIndex["{$d['enum']}|{$d['specValue']}"] = true; + } + } + $divergenceFieldIndex = []; + foreach ($divergences['fields'] as $d) { + $divergenceFieldIndex["{$d['schema']}|{$d['field']}"] = true; + } + + $classIndex = scanAllClasses($root); + + $mapErrors = validateMap($map, $classIndex, $root); + $mapErrors = array_map(fn ($e) => "NEEDS_HUMAN: {$e}", $mapErrors); + + $reachableOld = computeReachable($oldSpec); + $reachableNew = computeReachable($newSpec); + + if ($opts['auditTypes']) { + $audit = auditTypes($map, $newSpec, $reachableNew, $classIndex, $divergenceFieldIndex); + fwrite(STDOUT, "[api-sync] --audit-types: full state comparison of every mapped property's spec type vs SDK type.\nNon-blocking and informational -- pre-existing mismatches are Phase C triage, not a gate.\n\n"); + if (empty($audit['findings'])) { + fwrite(STDOUT, "No type mismatches found.\n"); + } + foreach ($audit['findings'] as $f) { + $tags = []; + if ($f['categoryMismatch']) { + $tags[] = 'category'; + } + if ($f['nullabilityMismatch']) { + $tags[] = 'nullability'; + } + $recorded = $f['recordedDivergence'] ? ' [recorded in known-divergences.json]' : ' [NOT YET RECORDED]'; + fwrite(STDOUT, " - {$f['field']} ({$f['class']}): spec={$f['specType']} sdk={$f['sdkType']} mismatch=".implode('+', $tags).$recorded."\n"); + } + if (! empty($audit['skippedFanOuts'])) { + fwrite(STDOUT, "\nSkipped (discriminator fan-out -- per-rail requiredness genuinely differs from the flattened schema): ".implode(', ', $audit['skippedFanOuts'])."\n"); + } + if ($reportPath !== null) { + file_put_contents($reportPath, encodeJsonDeterministic(['mode' => 'audit-types', 'spec' => $specPath, 'audit' => $audit])); + } + exit(0); + } + + $structuralIssues = empty($mapErrors) ? computeStructuralDiff($oldSpec, $newSpec, $reachableOld, $reachableNew, $map, $classIndex) : []; + + [$enumApplicable, $enumNeedsHuman] = empty($mapErrors) + ? reconcileEnums($map, $newSpec, $reachableNew, $classIndex, $divergenceEnumIndex) + : [[], []]; + [$fieldApplicable, $fieldNeedsHuman] = empty($mapErrors) + ? reconcileTypes($map, $newSpec, $reachableNew, $classIndex, $unmodeledIndex, $divergenceFieldIndex) + : [[], []]; + + $coverage = computeCoverageReport($map, $newSpec, $reachableNew); + + $needsHuman = array_merge($mapErrors, $structuralIssues, $enumNeedsHuman, $fieldNeedsHuman); + + $applicable = array_merge($enumApplicable, $fieldApplicable); + usort($applicable, fn ($a, $b) => $a['sortKey'] <=> $b['sortKey']); + + $report = [ + 'mode' => $mode, + 'spec' => $specPath, + 'applied' => [], + 'needsHuman' => $needsHuman, + 'bump' => null, + 'coverageGaps' => $coverage, + ]; + + if ($mode === 'check') { + if (! empty($needsHuman)) { + fwrite(STDERR, "\n[api-sync] FAIL -- needs a human decision:\n"); + foreach ($needsHuman as $line) { + fwrite(STDERR, " - {$line}\n"); + } + if ($reportPath !== null) { + file_put_contents($reportPath, encodeJsonDeterministic($report)); + } + exit(1); + } + if (! empty($applicable)) { + fwrite(STDERR, "\n[api-sync] FAIL -- pending drift, run `php scripts/api-sync.php --apply`:\n"); + foreach ($applicable as $c) { + if ($c['kind'] === 'enum-member-added') { + fwrite(STDERR, " - {$c['enum']}: missing case for spec value '{$c['value']}' ({$c['file']})\n"); + } else { + fwrite(STDERR, " - {$c['schema']}".($c['path'] ? ".{$c['path']}" : '')." -> {$c['class']}: missing field '{$c['field']}' ({$c['file']})\n"); + } + } + if ($reportPath !== null) { + $report['applied'] = $applicable; + file_put_contents($reportPath, encodeJsonDeterministic($report)); + } + exit(1); + } + if ($reportPath !== null) { + file_put_contents($reportPath, encodeJsonDeterministic($report)); + } + exit(0); + } + + // --apply + if (! empty($needsHuman)) { + fwrite(STDERR, "\n[api-sync] FAIL -- needs a human decision, nothing applied:\n"); + foreach ($needsHuman as $line) { + fwrite(STDERR, " - {$line}\n"); + } + if ($reportPath !== null) { + file_put_contents($reportPath, encodeJsonDeterministic($report)); + } + exit(1); + } + + if (empty($applicable)) { + if ($reportPath !== null) { + file_put_contents($reportPath, encodeJsonDeterministic($report)); + } + exit(0); + } + + // Precompute the full spec-ordered value list per enum (for anchor lookup), sorted deterministically. + $specValuesByEnum = []; + foreach ($map['enums'] as $entry) { + $values = enumSpecValues($newSpec, $entry); + if ($values !== null) { + $specValuesByEnum[$entry['sdk']['class']] = array_values($values); + } + } + + foreach ($applicable as $change) { + if ($change['kind'] === 'enum-member-added') { + applyEnumCaseInsertion($root, $change['file'], $change['enum'], $change['value'], $newSpec, $specValuesByEnum); + } else { + applyFieldInsertion($root, $change['file'], $change['class'], $change['field'], $change['propSchema']); + } + } + + $bump = computeBump($applicable); + if ($bump !== null) { + $newVersion = bumpVersion($root, $bump); + $report['bump'] = ['type' => $bump, 'version' => $newVersion]; + } + + // Refresh the snapshot with the SOURCE SPEC FILE'S BYTES, verbatim -- never re-serialize via + // json_decode/json_encode, which would silently reformat indentation, escaping and key order + // and turn every future sync PR into an ~86k-line unreviewable snapshot diff. + copy($specPath, resolveWithinRoot($root, '.api-sync/spec-snapshot.json', 'spec snapshot')); + + $report['applied'] = $applicable; + if ($reportPath !== null) { + file_put_contents($reportPath, encodeJsonDeterministic($report)); + } + + fwrite(STDOUT, '[api-sync] applied '.count($applicable).' change(s)'.($bump !== null ? ", version bump: {$bump} -> {$report['bump']['version']}" : '').".\n"); + exit(0); +} + +if (! defined('API_SYNC_LIB_ONLY')) { + runCli($argv, $root); +} diff --git a/src/BlindPay.php b/src/BlindPay.php index 41b8c8c..ca44cd3 100644 --- a/src/BlindPay.php +++ b/src/BlindPay.php @@ -39,7 +39,7 @@ class BlindPay implements ApiClientInterface { private const BASE_URL = 'https://api.blindpay.com/v1/'; - private const VERSION = '3.0.0'; + private const VERSION = '3.1.0'; private Client $httpClient; diff --git a/src/Resources/Quotes/Quotes.php b/src/Resources/Quotes/Quotes.php index 94032fb..da98c3a 100644 --- a/src/Resources/Quotes/Quotes.php +++ b/src/Resources/Quotes/Quotes.php @@ -25,7 +25,8 @@ public function __construct( public TransactionDocumentType $transactionDocumentType, public ?Network $network = null, public ?StablecoinToken $token = null, - public ?string $description = null + public ?string $description = null, + public ?string $refundWalletAddress = null ) {} public function toArray(): array @@ -42,6 +43,7 @@ public function toArray(): array 'network' => $this->network?->value, 'token' => $this->token?->value, 'description' => $this->description, + 'refund_wallet_address' => $this->refundWalletAddress, ]; } } diff --git a/src/Types/BankingPartner.php b/src/Types/BankingPartner.php index 39d0786..ea170f7 100644 --- a/src/Types/BankingPartner.php +++ b/src/Types/BankingPartner.php @@ -10,4 +10,5 @@ enum BankingPartner: string case CITI = 'citi'; case HSBC = 'hsbc'; case CFSB = 'cfsb'; + case PORTAGE = 'portage'; } diff --git a/src/Types/BusinessIndustry.php b/src/Types/BusinessIndustry.php index 8d360c3..9ed578b 100644 --- a/src/Types/BusinessIndustry.php +++ b/src/Types/BusinessIndustry.php @@ -118,6 +118,7 @@ enum BusinessIndustry: string case NAICS_455219 = '455219'; case NAICS_424210 = '424210'; case NAICS_456110 = '456110'; + case NAICS_446120 = '446120'; case NAICS_541511 = '541511'; case NAICS_541512 = '541512'; case NAICS_541519 = '541519'; diff --git a/src/Types/Currency.php b/src/Types/Currency.php index e9d30c9..0aa4457 100644 --- a/src/Types/Currency.php +++ b/src/Types/Currency.php @@ -14,4 +14,5 @@ enum Currency: string case MXN = 'MXN'; case COP = 'COP'; case ARS = 'ARS'; + case EUR = 'EUR'; } diff --git a/tests/ApiSync/ApiSyncTest.php b/tests/ApiSync/ApiSyncTest.php new file mode 100644 index 0000000..dfdf9ae --- /dev/null +++ b/tests/ApiSync/ApiSyncTest.php @@ -0,0 +1,924 @@ +fixtureRoot = sys_get_temp_dir().'/blindpay-api-sync-test-'.bin2hex(random_bytes(6)); + mkdir("{$this->fixtureRoot}/src/Types", 0777, true); + mkdir("{$this->fixtureRoot}/src/Resources/Widgets", 0777, true); + + file_put_contents("{$this->fixtureRoot}/src/Types/WidgetColor.php", <<<'PHP' + fixtureRoot}/src/Resources/Widgets/Widgets.php", <<<'PHP' + $this->name, + ]; + } + } + + readonly class WidgetInputConditional + { + public function __construct( + public string $name, + public ?string $extra = null + ) {} + + public function toArray(): array + { + $data = [ + 'name' => $this->name, + ]; + + if ($this->extra !== null) { + $data['extra'] = $this->extra; + } + + return $data; + } + } + + readonly class WidgetNoToArray + { + public function __construct( + public string $id + ) {} + + public static function fromArray(array $data): self + { + return new self( + id: $data['id'] + ); + } + } + + PHP); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->fixtureRoot); + } + + private function removeDirectory(string $dir): void + { + if (! is_dir($dir)) { + return; + } + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($items as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($dir); + } + + private function baseSpec(array $overrides = []): array + { + $spec = [ + 'paths' => [ + '/v1/widgets' => [ + 'post' => [ + 'requestBody' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/WidgetOut']]]], + 'responses' => ['200' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/WidgetOut']]]]], + ], + ], + ], + 'webhooks' => [], + 'components' => [ + 'schemas' => [ + 'WidgetOut' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'color' => ['type' => 'string', 'enum' => ['red', 'blue']], + ], + ], + ], + ], + ]; + + return array_replace_recursive($spec, $overrides); + } + + private function widgetMap(): array + { + return [ + 'enums' => [ + [ + 'spec' => ['schema' => 'WidgetOut', 'property' => 'color'], + 'sdk' => ['file' => 'src/Types/WidgetColor.php', 'class' => 'WidgetColor'], + ], + ], + 'types' => [ + [ + 'spec' => 'WidgetOut', + 'sdk' => [['file' => 'src/Resources/Widgets/Widgets.php', 'class' => 'WidgetResponse']], + ], + ], + 'ignore' => ['schemas' => []], + ]; + } + + // ---- enum case insertion ---- + + #[Test] + public function it_detects_a_missing_enum_case_as_applicable(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $newSpec = $this->baseSpec(['components' => ['schemas' => ['WidgetOut' => ['properties' => ['color' => ['enum' => ['red', 'blue', 'green']]]]]]]); + $reachable = computeReachable($newSpec); + + [$applicable, $needsHuman] = reconcileEnums($this->widgetMap(), $newSpec, $reachable, $classIndex, []); + + $this->assertEmpty($needsHuman); + $this->assertCount(1, $applicable); + $this->assertSame('enum-member-added', $applicable[0]['kind']); + $this->assertSame('green', $applicable[0]['value']); + } + + #[Test] + public function it_applies_a_missing_enum_case_following_spec_order(): void + { + $newSpec = $this->baseSpec(['components' => ['schemas' => ['WidgetOut' => ['properties' => ['color' => ['enum' => ['red', 'green', 'blue']]]]]]]); + $specValuesByEnum = ['WidgetColor' => ['red', 'green', 'blue']]; + + applyEnumCaseInsertion($this->fixtureRoot, 'src/Types/WidgetColor.php', 'WidgetColor', 'green', $newSpec, $specValuesByEnum); + + $source = file_get_contents("{$this->fixtureRoot}/src/Types/WidgetColor.php"); + $this->assertStringContainsString("case RED = 'red';\n case GREEN = 'green';\n case BLUE = 'blue';", $source); + } + + #[Test] + public function it_honors_a_known_divergence_and_does_not_flag_a_mismatched_enum_value_as_missing(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $newSpec = $this->baseSpec(['components' => ['schemas' => ['WidgetOut' => ['properties' => ['color' => ['enum' => ['red', 'blu']]]]]]]); // spec has a typo'd value + $reachable = computeReachable($newSpec); + $divergenceIndex = ['WidgetColor|blu' => true]; + + [$applicable, $needsHuman] = reconcileEnums($this->widgetMap(), $newSpec, $reachable, $classIndex, $divergenceIndex); + + $this->assertEmpty($needsHuman); + $this->assertEmpty($applicable); + } + + // ---- field insertion: three-part (constructor + fromArray + toArray) ---- + + #[Test] + public function it_detects_a_missing_field_as_applicable(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $newSpec = $this->baseSpec(['components' => ['schemas' => ['WidgetOut' => ['properties' => ['description' => ['type' => ['string', 'null']]]]]]]); + $reachable = computeReachable($newSpec); + + [$applicable, $needsHuman] = reconcileTypes($this->widgetMap(), $newSpec, $reachable, $classIndex, []); + + $this->assertEmpty($needsHuman); + $this->assertCount(1, $applicable); + $this->assertSame('field-added', $applicable[0]['kind']); + $this->assertSame('description', $applicable[0]['field']); + $this->assertSame('WidgetResponse', $applicable[0]['class']); + } + + #[Test] + public function it_applies_the_three_part_insertion_for_a_class_with_both_fromarray_and_toarray(): void + { + // WidgetInputConditional has neither fromArray nor a *response* shape by itself; use a + // class with both fromArray and toArray to exercise all three edits at once. + file_put_contents("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php", <<<'PHP' + $this->id, + 'name' => $this->name, + ]; + } + } + + PHP); + + applyFieldInsertion( + $this->fixtureRoot, + 'src/Resources/Widgets/Widgets.php', + 'WidgetBoth', + 'description', + ['type' => ['string', 'null']] + ); + + $source = file_get_contents("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php"); + + $this->assertStringContainsString('public string $name,', $source); + $this->assertStringContainsString('public ?string $description = null', $source); + $this->assertStringContainsString("name: \$data['name'],", $source); + $this->assertStringContainsString("description: \$data['description'] ?? null", $source); + $this->assertStringContainsString("'name' => \$this->name,", $source); + $this->assertStringContainsString("'description' => \$this->description,", $source); + + // still valid PHP + $this->assertPhpFileParses("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php"); + } + + #[Test] + public function it_applies_only_two_edits_for_a_class_without_toarray(): void + { + applyFieldInsertion( + $this->fixtureRoot, + 'src/Resources/Widgets/Widgets.php', + 'WidgetNoToArray', + 'label', + ['type' => 'string'] + ); + + $source = file_get_contents("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php"); + $this->assertStringContainsString('public ?string $label = null', $source); + $this->assertStringContainsString("label: \$data['label'] ?? null", $source); + // no toArray in this class at all -- nothing named 'label' => should appear + $this->assertStringNotContainsString("'label' =>", $source); + + $this->assertPhpFileParses("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php"); + } + + #[Test] + public function it_uses_the_literal_style_for_a_class_whose_toarray_is_an_unconditional_array(): void + { + applyFieldInsertion( + $this->fixtureRoot, + 'src/Resources/Widgets/Widgets.php', + 'WidgetInputLiteral', + 'nickname', + ['type' => ['string', 'null']] + ); + + $source = file_get_contents("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php"); + $this->assertStringContainsString("'nickname' => \$this->nickname,", $source); + $this->assertStringNotContainsString('if ($this->nickname !== null)', $source); + $this->assertPhpFileParses("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php"); + } + + #[Test] + public function it_uses_the_conditional_style_for_a_class_whose_toarray_already_conditionally_includes_fields(): void + { + applyFieldInsertion( + $this->fixtureRoot, + 'src/Resources/Widgets/Widgets.php', + 'WidgetInputConditional', + 'nickname', + ['type' => ['string', 'null']] + ); + + $source = file_get_contents("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php"); + $this->assertStringContainsString('if ($this->nickname !== null) {', $source); + $this->assertStringContainsString("\$data['nickname'] = \$this->nickname;", $source); + $this->assertPhpFileParses("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php"); + } + + #[Test] + public function it_honors_unmodeled_json_and_does_not_flag_a_listed_field_as_missing(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $newSpec = $this->baseSpec(['components' => ['schemas' => ['WidgetOut' => ['properties' => ['legacy_field' => ['type' => 'string']]]]]]); + $reachable = computeReachable($newSpec); + $unmodeledIndex = ['WidgetOut||legacy_field' => true]; + + [$applicable, $needsHuman] = reconcileTypes($this->widgetMap(), $newSpec, $reachable, $classIndex, $unmodeledIndex); + + $this->assertEmpty($needsHuman); + $this->assertEmpty($applicable); + } + + // ---- idempotency ---- + + #[Test] + public function applying_a_field_twice_only_inserts_it_once(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $newSpec = $this->baseSpec(['components' => ['schemas' => ['WidgetOut' => ['properties' => ['description' => ['type' => ['string', 'null']]]]]]]); + $reachable = computeReachable($newSpec); + + [$applicable] = reconcileTypes($this->widgetMap(), $newSpec, $reachable, $classIndex, []); + $this->assertCount(1, $applicable); + applyFieldInsertion($this->fixtureRoot, $applicable[0]['file'], $applicable[0]['class'], $applicable[0]['field'], $applicable[0]['propSchema']); + + // re-scan and reconcile again -- should now be fully caught up, nothing left to apply + $classIndex = scanAllClasses($this->fixtureRoot); + [$applicableAgain, $needsHumanAgain] = reconcileTypes($this->widgetMap(), $newSpec, $reachable, $classIndex, []); + $this->assertEmpty($applicableAgain); + $this->assertEmpty($needsHumanAgain); + + // one edit each in the constructor, fromArray and toArray -- never a duplicate insertion. + $source = file_get_contents("{$this->fixtureRoot}/{$applicable[0]['file']}"); + $this->assertSame(3, substr_count($source, 'description')); + } + + // ---- NEEDS_HUMAN: removals ---- + + #[Test] + public function it_flags_a_removed_property_as_needs_human(): void + { + $oldSpec = $this->baseSpec(); + $newSpec = $this->baseSpec(); + unset($newSpec['components']['schemas']['WidgetOut']['properties']['name']); + $reachableOld = computeReachable($oldSpec); + $reachableNew = computeReachable($newSpec); + + $issues = computeStructuralDiff($oldSpec, $newSpec, $reachableOld, $reachableNew, $this->widgetMap()); + + $this->assertNotEmpty(array_filter($issues, fn ($i) => str_contains($i, 'removed from WidgetOut') && str_contains($i, 'name'))); + } + + #[Test] + public function it_flags_a_removed_enum_value_as_needs_human(): void + { + $oldSpec = $this->baseSpec(); + $newSpec = $this->baseSpec(); + // array_replace_recursive merges indexed arrays by key, it cannot truncate one -- replace outright. + $newSpec['components']['schemas']['WidgetOut']['properties']['color']['enum'] = ['red']; + $reachableOld = computeReachable($oldSpec); + $reachableNew = computeReachable($newSpec); + + $issues = computeStructuralDiff($oldSpec, $newSpec, $reachableOld, $reachableNew, $this->widgetMap()); + + $this->assertNotEmpty(array_filter($issues, fn ($i) => str_contains($i, 'enum value(s) removed from WidgetColor') && str_contains($i, 'blue'))); + } + + #[Test] + public function it_flags_a_removed_operation_as_needs_human(): void + { + $oldSpec = $this->baseSpec(); + $newSpec = $this->baseSpec(); + unset($newSpec['paths']['/v1/widgets']['post']); + unset($newSpec['paths']['/v1/widgets']); + $reachableOld = computeReachable($oldSpec); + $reachableNew = computeReachable($newSpec); + + $issues = computeStructuralDiff($oldSpec, $newSpec, $reachableOld, $reachableNew, $this->widgetMap()); + + $this->assertNotEmpty(array_filter($issues, fn ($i) => str_contains($i, 'operation removed: post /v1/widgets'))); + } + + #[Test] + public function it_flags_a_new_operation_as_needs_human(): void + { + $oldSpec = $this->baseSpec(); + $newSpec = $this->baseSpec(); + $newSpec['paths']['/v1/gadgets'] = ['post' => ['requestBody' => [], 'responses' => []]]; + $reachableOld = computeReachable($oldSpec); + $reachableNew = computeReachable($newSpec); + + $issues = computeStructuralDiff($oldSpec, $newSpec, $reachableOld, $reachableNew, $this->widgetMap()); + + $this->assertNotEmpty(array_filter($issues, fn ($i) => str_contains($i, 'new operation: post /v1/gadgets'))); + } + + #[Test] + public function it_flags_a_new_reachable_unmapped_schema_as_needs_human(): void + { + $oldSpec = $this->baseSpec(); + $newSpec = $this->baseSpec(); + $newSpec['components']['schemas']['GadgetOut'] = ['type' => 'object', 'properties' => ['id' => ['type' => 'string']]]; + $newSpec['paths']['/v1/widgets']['post']['responses']['200']['content']['application/json']['schema'] = ['$ref' => '#/components/schemas/GadgetOut']; + $reachableOld = computeReachable($oldSpec); + $reachableNew = computeReachable($newSpec); + + $issues = computeStructuralDiff($oldSpec, $newSpec, $reachableOld, $reachableNew, $this->widgetMap()); + + $this->assertNotEmpty(array_filter($issues, fn ($i) => str_contains($i, 'new schema: GadgetOut'))); + } + + #[Test] + public function a_new_unreferenced_schema_is_not_flagged_at_all(): void + { + $oldSpec = $this->baseSpec(); + $newSpec = $this->baseSpec(); + $newSpec['components']['schemas']['OrphanOut'] = ['type' => 'object', 'properties' => ['id' => ['type' => 'string']]]; + $reachableOld = computeReachable($oldSpec); + $reachableNew = computeReachable($newSpec); + + $issues = computeStructuralDiff($oldSpec, $newSpec, $reachableOld, $reachableNew, $this->widgetMap()); + + $this->assertEmpty(array_filter($issues, fn ($i) => str_contains($i, 'OrphanOut'))); + } + + // ---- NEEDS_HUMAN: type mismatches on already-modeled properties ---- + + #[Test] + public function a_string_property_changing_to_integer_in_the_spec_is_needs_human(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $newSpec = $this->baseSpec(); + $newSpec['components']['schemas']['WidgetOut']['properties']['name'] = ['type' => 'integer']; + $reachable = computeReachable($newSpec); + + [$applicable, $needsHuman] = reconcileTypes($this->widgetMap(), $newSpec, $reachable, $classIndex, []); + + $this->assertEmpty($applicable); + $this->assertNotEmpty(array_filter($needsHuman, fn ($i) => str_contains($i, 'WidgetOut.name') && str_contains($i, 'integer') && str_contains($i, 'string'))); + } + + #[Test] + public function a_property_newly_allowing_null_while_the_sdk_stays_non_nullable_is_needs_human(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $oldSpec = $this->baseSpec(); // WidgetOut.name: {"type": "string"} -- non-nullable + $newSpec = $this->baseSpec(); + $newSpec['components']['schemas']['WidgetOut']['properties']['name'] = ['type' => ['string', 'null']]; + $reachableOld = computeReachable($oldSpec); + $reachableNew = computeReachable($newSpec); + + // WidgetResponse declares `public string $name` (no `?`) -- a real null would break it. + $issues = computeStructuralDiff($oldSpec, $newSpec, $reachableOld, $reachableNew, $this->widgetMap(), $classIndex); + + $this->assertNotEmpty(array_filter($issues, fn ($i) => str_contains($i, 'WidgetOut.name') && str_contains($i, 'newly allows null') && str_contains($i, 'string'))); + } + + #[Test] + public function an_enum_backed_sdk_property_whose_spec_constraint_degrades_to_a_bare_string_is_needs_human(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $newSpec = $this->baseSpec(); + // WidgetResponse declares `public WidgetColor $color` -- an enum class. If the spec drops + // the enum constraint down to a bare, unconstrained string, WidgetColor::from() can now + // throw on a value outside its known set. + $newSpec['components']['schemas']['WidgetOut']['properties']['color'] = ['type' => 'string']; + $reachable = computeReachable($newSpec); + + [$applicable, $needsHuman] = reconcileTypes($this->widgetMap(), $newSpec, $reachable, $classIndex, []); + + $this->assertEmpty($applicable); + $this->assertNotEmpty(array_filter($needsHuman, fn ($i) => str_contains($i, 'WidgetOut.color'))); + } + + #[Test] + public function an_integer_spec_property_modeled_as_a_php_float_is_deliberately_treated_as_compatible(): void + { + // PHP widens int to float safely, even under strict_types -- a spec `integer` read into a + // `float`-typed property parses every value without error, so this is not a mismatch. + $spec = specTypeCategory(['type' => 'integer']); + $php = phpTypeCategory('float'); + + $this->assertTrue(categoriesCompatible($spec, $php)); + } + + // ---- constructor insertion never reorders existing parameters ---- + + #[Test] + public function the_new_promoted_property_always_lands_last_and_no_existing_parameter_moves(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $before = array_column($classIndex['WidgetResponse']['ctor']['params'], 'name'); + + applyFieldInsertion( + $this->fixtureRoot, + 'src/Resources/Widgets/Widgets.php', + 'WidgetResponse', + 'nickname', + ['type' => ['string', 'null']] + ); + + $after = array_column(scanAllClasses($this->fixtureRoot)['WidgetResponse']['ctor']['params'], 'name'); + + $this->assertSame([...$before, 'nickname'], $after, 'existing parameters must keep their exact order, with the new one appended last'); + $this->assertPhpFileParses("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php"); + } + + // ---- map validity ---- + + #[Test] + public function it_reports_a_map_anchor_that_does_not_exist(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $map = $this->widgetMap(); + $map['types'][0]['sdk'][0]['class'] = 'NoSuchClass'; + + $errors = validateMap($map, $classIndex, $this->fixtureRoot); + + $this->assertNotEmpty(array_filter($errors, fn ($e) => str_contains($e, 'NoSuchClass'))); + } + + #[Test] + public function it_reports_a_map_anchor_at_the_wrong_file(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $map = $this->widgetMap(); + $map['types'][0]['sdk'][0]['file'] = 'src/Resources/Widgets/WrongFile.php'; + + $errors = validateMap($map, $classIndex, $this->fixtureRoot); + + $this->assertNotEmpty(array_filter($errors, fn ($e) => str_contains($e, 'mismatch'))); + } + + #[Test] + public function a_valid_map_produces_no_errors(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $errors = validateMap($this->widgetMap(), $classIndex, $this->fixtureRoot); + $this->assertEmpty($errors); + } + + // ---- version bump classification ---- + + #[Test] + public function an_enum_member_addition_bumps_minor(): void + { + $this->assertSame('minor', computeBump([['kind' => 'enum-member-added']])); + } + + #[Test] + public function a_field_only_addition_bumps_patch(): void + { + $this->assertSame('patch', computeBump([['kind' => 'field-added']])); + } + + #[Test] + public function a_mix_of_enum_and_field_changes_bumps_minor(): void + { + $this->assertSame('minor', computeBump([['kind' => 'field-added'], ['kind' => 'enum-member-added']])); + } + + #[Test] + public function no_changes_means_no_bump(): void + { + $this->assertNull(computeBump([])); + } + + #[Test] + public function version_string_bumps_correctly(): void + { + $this->assertSame('3.1.0', bumpVersionString('3.0.0', 'minor')); + $this->assertSame('3.0.6', bumpVersionString('3.0.5', 'patch')); + $this->assertSame('3.1.0', bumpVersionString('3.0.9', 'minor')); + } + + // ---- --audit-types: non-blocking, full state comparison (not just forward drift) ---- + + #[Test] + public function audit_types_reports_a_pre_existing_nullability_mismatch_that_check_mode_would_not_flag(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + // WidgetResponse declares `public string $name` (non-nullable); the spec allows null. + // This did NOT change between "old" and "new" here -- audit-types must still surface it, + // unlike the blocking checks, which only fire on genuinely new drift. + $spec = $this->baseSpec(); + $spec['components']['schemas']['WidgetOut']['properties']['name'] = ['type' => ['string', 'null']]; + $reachable = computeReachable($spec); + + $audit = auditTypes($this->widgetMap(), $spec, $reachable, $classIndex, []); + + $finding = current(array_filter($audit['findings'], fn ($f) => $f['field'] === 'WidgetOut.name')); + $this->assertNotFalse($finding); + $this->assertTrue($finding['nullabilityMismatch']); + $this->assertFalse($finding['categoryMismatch']); + $this->assertFalse($finding['recordedDivergence']); + } + + #[Test] + public function audit_types_marks_a_recorded_known_divergence_instead_of_hiding_it(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $spec = $this->baseSpec(); + $spec['components']['schemas']['WidgetOut']['properties']['name'] = ['type' => ['string', 'null']]; + $reachable = computeReachable($spec); + + $audit = auditTypes($this->widgetMap(), $spec, $reachable, $classIndex, ['WidgetOut|name' => true]); + + $finding = current(array_filter($audit['findings'], fn ($f) => $f['field'] === 'WidgetOut.name')); + $this->assertNotFalse($finding); + $this->assertTrue($finding['recordedDivergence'], 'a recorded divergence must still be reported, just marked, never hidden'); + } + + #[Test] + public function audit_types_skips_discriminator_fan_outs_instead_of_guessing(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $map = $this->widgetMap(); + // Simulate a fan-out: the same WidgetOut schema modeled by two classes. + $map['types'][0]['sdk'][] = ['file' => 'src/Resources/Widgets/Widgets.php', 'class' => 'WidgetInputLiteral']; + $spec = $this->baseSpec(); + $reachable = computeReachable($spec); + + $audit = auditTypes($map, $spec, $reachable, $classIndex, []); + + $this->assertContains('WidgetOut', $audit['skippedFanOuts']); + $this->assertEmpty($audit['findings']); + } + + #[Test] + public function audit_types_cli_mode_always_exits_zero_even_with_findings(): void + { + // Reuse the byte-copy fixture wiring (real script copy + full .api-sync/*.json set), but + // seed a spec with a deliberate nullability mismatch so --audit-types has something to say. + mkdir("{$this->fixtureRoot}/.api-sync", 0777, true); + mkdir("{$this->fixtureRoot}/scripts", 0777, true); + copy(__DIR__.'/../../scripts/api-sync.php', "{$this->fixtureRoot}/scripts/api-sync.php"); + file_put_contents("{$this->fixtureRoot}/src/BlindPay.php", "fixtureRoot}/.api-sync/spec-map.json", json_encode($this->widgetMap())); + file_put_contents("{$this->fixtureRoot}/.api-sync/unmodeled.json", json_encode(['entries' => []])); + file_put_contents("{$this->fixtureRoot}/.api-sync/known-divergences.json", json_encode(['enumValues' => [], 'fields' => []])); + file_put_contents("{$this->fixtureRoot}/.api-sync/spec-snapshot.json", json_encode($this->baseSpec())); + + $spec = $this->baseSpec(); + $spec['components']['schemas']['WidgetOut']['properties']['name'] = ['type' => ['string', 'null']]; + $specPath = "{$this->fixtureRoot}/.api-sync/spec-current.json"; + file_put_contents($specPath, json_encode($spec)); + + exec(sprintf( + 'php %s --audit-types --spec=%s 2>&1', + escapeshellarg("{$this->fixtureRoot}/scripts/api-sync.php"), + escapeshellarg($specPath) + ), $output, $exitCode); + + $this->assertSame(0, $exitCode); + $this->assertNotEmpty(array_filter($output, fn ($l) => str_contains($l, 'WidgetOut.name'))); + } + + // ---- path validation: every path from a CLI arg or spec-map.json is resolved and validated + // ---- before any read/write touches it, and writes derived from spec-map.json cannot escape + // ---- the repository root. + + #[Test] + public function resolve_readable_path_accepts_an_existing_file_and_returns_its_canonical_form(): void + { + $file = "{$this->fixtureRoot}/src/BlindPay.php"; + file_put_contents($file, "assertSame(realpath($file), $resolved); + } + + #[Test] + public function resolve_readable_path_rejects_a_nul_byte(): void + { + // A NUL byte cannot survive in a real argv element (execve() argv strings are + // NUL-terminated), so this is tested as a direct unit call, isolated in a subprocess since + // resolveReadablePath() calls exit(1) on rejection, which would otherwise kill the test + // runner. The `"\0"` is a PHP string escape evaluated at runtime by the subprocess, not a + // raw byte passed through shell argv. + [$output, $exitCode] = $this->runPhpSnippetInSubprocess( + 'resolveReadablePath("/tmp/whatever"."\0".".json", "--spec");' + ); + + $this->assertSame(1, $exitCode); + $this->assertNotEmpty(array_filter($output, fn ($l) => str_contains($l, 'invalid --spec path'))); + } + + #[Test] + public function resolve_readable_path_rejects_a_file_that_does_not_exist(): void + { + [$output, $exitCode] = $this->runCliSubprocess(['--check', '--spec='."{$this->fixtureRoot}/does-not-exist.json"]); + + $this->assertSame(1, $exitCode); + $this->assertNotEmpty(array_filter($output, fn ($l) => str_contains($l, 'does not exist'))); + } + + #[Test] + public function resolve_writable_path_rejects_a_parent_directory_that_does_not_exist(): void + { + [$output, $exitCode] = $this->runCliSubprocess([ + '--check', + '--spec='."{$this->fixtureRoot}/.api-sync/spec-snapshot.json", + '--report='."{$this->fixtureRoot}/no-such-dir/report.json", + ]); + + $this->assertSame(1, $exitCode); + $this->assertNotEmpty(array_filter($output, fn ($l) => str_contains($l, 'directory for --report does not exist'))); + } + + #[Test] + public function resolve_writable_path_accepts_a_destination_outside_the_repository_root(): void + { + // Required, legitimate functionality: CI writes --report to /tmp; the determinism proof + // writes into scratch copies elsewhere on disk. Writable paths are deliberately NOT + // restricted to the repository root (only spec-map.json-driven writes are, see below). + $outside = sys_get_temp_dir().'/blindpay-api-sync-report-'.bin2hex(random_bytes(6)).'.json'; + + $resolved = resolveWritablePath($outside, '--report'); + + $this->assertSame(realpath(dirname($outside)).'/'.basename($outside), $resolved); + } + + #[Test] + public function resolve_within_root_accepts_a_legitimate_spec_map_file(): void + { + $resolved = resolveWithinRoot($this->fixtureRoot, 'src/Resources/Widgets/Widgets.php', 'class file'); + + $this->assertSame(realpath("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php"), $resolved); + } + + #[Test] + public function resolve_within_root_refuses_a_path_that_escapes_the_repository_root(): void + { + // Simulates a corrupted or malicious spec-map.json entry trying to write outside the repo. + // Unit-tested directly against resolveWithinRoot(): going through the full CLI, validateMap() + // would independently catch this first (the named class isn't actually declared at the + // claimed file), which is good defense in depth but would mask the check this test targets. + $escapeDir = dirname($this->fixtureRoot).'/escape-target-'.basename($this->fixtureRoot); + mkdir($escapeDir, 0777, true); + + try { + [$output, $exitCode] = $this->runPhpSnippetInSubprocess(sprintf( + 'resolveWithinRoot(%s, %s, "class file");', + var_export($this->fixtureRoot, true), + var_export('../'.basename($escapeDir).'/evil.php', true) + )); + + $this->assertSame(1, $exitCode); + $this->assertNotEmpty(array_filter($output, fn ($l) => str_contains($l, 'resolves outside the repository root'))); + $this->assertFileDoesNotExist("{$escapeDir}/evil.php"); + } finally { + $this->removeDirectory($escapeDir); + } + } + + /** @return array{0: array, 1: int} */ + private function runCliSubprocess(array $args): array + { + mkdir("{$this->fixtureRoot}/.api-sync", 0777, true); + mkdir("{$this->fixtureRoot}/scripts", 0777, true); + copy(__DIR__.'/../../scripts/api-sync.php', "{$this->fixtureRoot}/scripts/api-sync.php"); + file_put_contents("{$this->fixtureRoot}/src/BlindPay.php", "fixtureRoot}/.api-sync/spec-map.json", json_encode($this->widgetMap())); + file_put_contents("{$this->fixtureRoot}/.api-sync/unmodeled.json", json_encode(['entries' => []])); + file_put_contents("{$this->fixtureRoot}/.api-sync/known-divergences.json", json_encode(['enumValues' => [], 'fields' => []])); + file_put_contents("{$this->fixtureRoot}/.api-sync/spec-snapshot.json", json_encode($this->baseSpec())); + + $cmd = 'php '.escapeshellarg("{$this->fixtureRoot}/scripts/api-sync.php"); + foreach ($args as $arg) { + $cmd .= ' '.escapeshellarg($arg); + } + exec($cmd.' 2>&1', $output, $exitCode); + + return [$output, $exitCode]; + } + + /** + * Runs one PHP statement in a fresh subprocess that has already `require_once`'d api-sync.php + * in lib-only mode, so a helper under test that calls exit() on failure doesn't kill the test + * runner. Used to unit-test resolveReadablePath()/resolveWithinRoot()'s exit(1) paths directly. + * + * @return array{0: array, 1: int} + */ + private function runPhpSnippetInSubprocess(string $statement): array + { + $script = $this->fixtureRoot.'/snippet.php'; + file_put_contents($script, sprintf( + "&1', $output, $exitCode); + + return [$output, $exitCode]; + } + + // ---- snapshot refresh must copy bytes verbatim, never re-serialize ---- + + #[Test] + public function apply_refreshes_the_snapshot_as_a_byte_identical_copy_of_the_source_spec(): void + { + // A full, real invocation via a copy of the real script, run as a subprocess against a + // throwaway repo root -- exercises the exact code path apply uses, including the file + // copy, without exiting the test process. + mkdir("{$this->fixtureRoot}/.api-sync", 0777, true); + mkdir("{$this->fixtureRoot}/scripts", 0777, true); + copy(__DIR__.'/../../scripts/api-sync.php', "{$this->fixtureRoot}/scripts/api-sync.php"); + file_put_contents("{$this->fixtureRoot}/src/BlindPay.php", <<<'PHP' + fixtureRoot}/.api-sync/spec-map.json", json_encode($this->widgetMap())); + file_put_contents("{$this->fixtureRoot}/.api-sync/unmodeled.json", json_encode(['entries' => []])); + file_put_contents("{$this->fixtureRoot}/.api-sync/known-divergences.json", json_encode(['enumValues' => [], 'fields' => []])); + file_put_contents("{$this->fixtureRoot}/.api-sync/spec-snapshot.json", json_encode($this->baseSpec(), JSON_PRETTY_PRINT)); + + // Deliberately unusual formatting (2-space indent, escaped slashes, a trailing newline) so + // a round-tripped json_decode/json_encode would visibly differ from a raw byte copy. + $newSpec = $this->baseSpec(); + $newSpec['components']['schemas']['WidgetOut']['properties']['description'] = ['type' => ['string', 'null']]; + $sourceSpecPath = "{$this->fixtureRoot}/.api-sync/spec-current.json"; + // default json_encode escapes slashes and this appends a distinctive double trailing + // newline, so a re-serialized (json_decode + json_encode) snapshot would visibly differ. + file_put_contents($sourceSpecPath, json_encode($newSpec, JSON_PRETTY_PRINT).\PHP_EOL.\PHP_EOL); + + $cmd = sprintf( + 'php %s --apply --spec=%s 2>&1', + escapeshellarg("{$this->fixtureRoot}/scripts/api-sync.php"), + escapeshellarg($sourceSpecPath) + ); + exec($cmd, $output, $exitCode); + + $this->assertSame(0, $exitCode, 'apply failed: '.implode("\n", $output)); + $this->assertSame( + file_get_contents($sourceSpecPath), + file_get_contents("{$this->fixtureRoot}/.api-sync/spec-snapshot.json"), + 'refreshed snapshot must be a byte-identical copy of the source spec file, not a re-serialization' + ); + } + + // ---- helper assertion ---- + + private function assertPhpFileParses(string $path): void + { + $output = []; + $exitCode = 0; + exec('php -l '.escapeshellarg($path).' 2>&1', $output, $exitCode); + $this->assertSame(0, $exitCode, "php -l failed for {$path}: ".implode("\n", $output)); + } +}