fix: OpenAPI guardian sweep 2026-09-14 (wallets, coupons, applied coupons, alerts) - #581
Open
lago-claude-ai-agent[bot] wants to merge 3 commits into
Open
lago-claude-ai-agent[bot] wants to merge 3 commits into
lago-claude-ai-agent[bot] wants to merge 3 commits into
Conversation
WalletObject listed `balance` as a required property, but no such property is declared in the schema and V1::WalletSerializer never emits it. The wallet balance is exposed as `balance_cents` (and `credits_balance`), both of which are already declared and required. Evidence: app/serializers/v1/wallet_serializer.rb Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… and alerts
Four list endpoints honoured filters that the spec did not document:
- GET /customers/{external_customer_id}/wallets accepts `currency`
(Api::V1::Customers::WalletsController#index permits it and passes it
to WalletsQuery, which filters on balance_currency). The top-level
/wallets already documented it.
- GET /wallets/{lago_id}/wallet_transactions accepts `metadata[key]`
(WalletTransactionsQuery#with_metadata). Documented with the same
shape already used on GET /invoices.
- GET /subscriptions/{external_id}/alerts and
GET /customers/{external_customer_id}/wallets/{wallet_code}/alerts are
both paginated and already return a `meta` object, but `page` and
`per_page` were undocumented.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-api Response fields the API returns but the spec omitted: - AppliedCouponObject.coupon_description — emitted by V1::AppliedCouponSerializer from the parent coupon; nullable because coupons.description is a nullable text column. - WalletTransactionObject.billing_entity_code — emitted by V1::WalletTransactionSerializer, falling back to the wallet's billing entity. Non-nullable: customers.billing_entity_id is NOT NULL, so the fallback always resolves. Request fields the controllers permit but the spec omitted: - WalletCreateInput.transaction_priority — permitted by WalletActions #input_params and applied as the priority of the initial top-up transaction (Wallets::CreateService#schedule_top_up). Range 1-50, default 50, per WalletTransaction's inclusion validation. - WalletUpdateInput.paid_top_up_min_amount_cents / paid_top_up_max_amount_cents — permitted by WalletActions #update_params and applied by Wallets::UpdateService. - WalletTransactionCreateInput.priority — permitted by WalletTransactionsController#input_params, defaulting to 50. - WalletTransactionCreateInput.voided_transaction_id — permitted and validated by WalletTransactions::ValidateService, which requires a traceable wallet and an inbound transaction with a remaining amount. Also adds the missing top-level `required: [wallet]` to WalletCreateInput: the controller calls params.require(:wallet), and the sibling WalletUpdateInput already declared it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
OpenAPI Guardian sweep — 2026-09-14 (slice 6: wallets, coupons, applied_coupons, alerts)
Automated spec sweep vs lago-api and the SDK clients.
A human must review and merge — this agent never merges.
npm run buildandnpm run testpass on this branch (0 errors, 23 pre-existingarray-params-pluralwarnings, unchanged frommain). The regeneratedopenapi.yamlis committed alongside eachsrc/change.Fixed in this PR
Field-level evidence
Required vs optional
src/schemas/WalletObject.yamlrequired: listedbalance→ entry removed —balanceis not a declared property of the schema andV1::WalletSerializer#serializenever emits it; the balance is exposed asbalance_centsandcredits_balance, both already declared and required. Evidence:app/serializers/v1/wallet_serializer.rb.src/schemas/WalletCreateInput.yamltop level: norequired→required: [wallet]—WalletsController#customer_paramsandWalletActions#input_paramsboth callparams.require(:wallet), andApi::BaseControllermapsActionController::ParameterMissingto a 400 (app/controllers/api/base_controller.rb:16). The siblingWalletUpdateInputalready declared it. See [BREAKING-DOC] below.Missing response fields (API returns them, spec omitted them)
src/schemas/AppliedCouponObject.yamlcoupon_description: absent →["string","null"]— emitted byV1::AppliedCouponSerializerasmodel.coupon.description; nullable becausecoupons.descriptionis a nullabletextcolumn. Evidence:app/serializers/v1/applied_coupon_serializer.rb:11,db/structure.sql.src/schemas/WalletTransactionObject.yamlbilling_entity_code: absent →string— emitted byV1::WalletTransactionSerializer#billing_entity_code, falling back to the wallet's billing entity. Non-nullable:customers.billing_entity_idisNOT NULL, so the fallback always resolves. Evidence:app/serializers/v1/wallet_transaction_serializer.rb,db/structure.sql.Missing request fields (controllers permit them, spec omitted them)
src/schemas/WalletCreateInput.yamltransaction_priority: absent →integer(1–50, default 50) — permitted byWalletActions#input_paramsand applied as the priority of the initial top-up inWallets::CreateService#schedule_top_up. Range fromWalletTransaction'svalidates :priority, inclusion: {in: 1..50}. Evidence:app/controllers/concerns/wallet_actions.rb:109,app/services/wallets/create_service.rb:127.src/schemas/WalletUpdateInput.yamlpaid_top_up_min_amount_cents/paid_top_up_max_amount_cents: absent →["integer","null"]— permitted byWalletActions#update_paramsand applied byWallets::UpdateService(lines 45–46). Both already existed onWalletCreateInputandWalletObject.src/schemas/WalletTransactionCreateInput.yamlpriority: absent →integer(1–50, default 50) — permitted byWalletTransactionsController#input_params, defaulted inWalletTransactions::CreateFromParamsService(@priority = params[:priority] || 50).src/schemas/WalletTransactionCreateInput.yamlvoided_transaction_id: absent →["string","null"],format: uuid— permitted by the sameinput_paramsand validated byWalletTransactions::ValidateService, which requires a traceable wallet and an inbound transaction that still has a remaining amount.Filters / query params
src/resources/customer_wallets.yamlGET: addedcurrency—Api::V1::Customers::WalletsController#indexpermits:currencyand passes it toWalletsQuery, which filters onbalance_currency. The top-levelGET /walletsalready documented it; the Go client'sWalletListInputalso carries it.src/resources/wallet_transactions.yamlGET: addedmetadata[key]— honoured byWalletTransactionsQuery#with_metadatavia ajsonb_array_elementslookup. Documented with the samename: metadata[key]shape already used onGET /invoices.src/resources/subscription_alerts.yamlGET: addedpage,per_page—Subscriptions::AlertsController#indexpassespagination: {page:, limit:}and the response already declares ametaobject viaAlerts.yaml.src/resources/customer_wallet_alerts.yamlGET: addedpage,per_page— same, viaCustomers::Wallets::AlertsController#indexandWalletAlerts.yaml.[BREAKING-DOC] flags
WalletCreateInputtop-levelrequired: [wallet]. The code proves the wrapper is mandatory (params.require(:wallet)→ 400), so the spec was wrong. But on paper this is an optional→required tightening on the request body ofPOST /walletsandPOST /customers/{external_customer_id}/wallets, and it is what the compatibility checker reports asBLOCK. No caller that works today breaks — a request without thewalletwrapper already fails with 400 — but generated clients may start rejecting such payloads locally instead of sending them. Flagged so reviewers weigh it consciously.Contract compatibility impact
Decision: BLOCK — 2 blocking · 0 warning · 23 informational.
BLOCKis advisory and requires explicit human review; this agent never merges or approves.Both blocking findings are the single
WalletCreateInputrequiredness fix above, reported once per affected operation:There were no
WARNfindings. The remaining 23 findings are allINFO(additive request/response properties and added optional query parameters, requiring no caller migration):coupon_descriptionon the four applied-coupon responses;billing_entity_codeon the six wallet-transaction responses;transaction_priority,priority,voided_transaction_idand the twopaid_top_up_*request properties; and thecurrency,metadata[key],pageandper_pagequery parameters.Note that the
WalletObject.requiredfix does not appear in the report at all:balancewas never a declared property, so the checker's$ref/allOfresolver had nothing to compare.Customer exposure: unknown. No authorized read-only usage source was provided for this run, and the checker has no production telemetry, so no claim is made about how many consumers send a bare wallet payload today.
SDK drift (spec is right — needs an
sdk-clients-updaterun)transaction_prioritymissing from the create payload builders (Customers::Wallets::WhitelistParams#wallet,models/wallet.py::Wallet,WalletInput)paid_top_up_min/max_amount_centsalready present in all three hand-written clientspriorityandvoided_transaction_idmissing fromWalletTransaction#whitelist_params,models/wallet_transaction.py::WalletTransaction,WalletTransactionInputbilling_entity_codemissing fromWalletTransactionResponse/WalletTransaction(present on their wallet models)metadata[key]filter missing fromWalletTransactionListInputcoupon_descriptionmissing everywhere. Go and Rust also lackcoupon_statusandcoupon_deleted_at; Python also lackscoupon_name— all three already in the specdescriptionmissing frommodels/coupon.rs::Couponexternal_subscription_id,direction,lago_wallet_idandwallet_codemissing fromAlert; only the deprecatedsubscription_external_idis modelledopenapi/is generated at build time from the published spec and is not committed, so the client inherits these fixes on its next CI buildNeeds human confirmation (not changed)
billing_entity_idon wallet create.WalletActions#input_paramspermits:billing_entity_idalongside:billing_entity_code, andWallets::CreateService#billing_entityresolves either one. The spec documents onlybilling_entity_code, and every other public billing-entity reference in the spec is code-based. Adding a raw internal UUID lookup to the public contract is a product decision, so nothing was changed — please confirm whetherbilling_entity_idis intended as public API or is an internal/GraphQL-shared param.subscription_external_idon alert responses.V1::UsageMonitoring::AlertSerializeremits bothexternal_subscription_idandsubscription_external_id, the latter explicitly commented# DEPRECATEDin the code. NeitherAlertObjectnorWalletAlertObjectdeclares the deprecated alias. Documenting it withdeprecated: true(the pattern used for theinvoice.add_on_addedwebhook in fix(webhook): deprecate the stale invoice.add_on_added webhook #577) would be truthful, but advertising a deprecated alias is a maintainer call — left alone.requiredon wallet and alert response schemas.V1::WalletSerializerunconditionally emitsbilling_entity_code,priority,name,code,purchase_order_numberandpayment_method, andAlertSerializerunconditionally emitsdirectionandthresholds, yet none of these appear in the correspondingrequiredlists. This is the same class of finding raised forCustomerBaseObjectin fix: OpenAPI guardian sweep 2026-08-17 (slice 2 remainder + docs-guardian leads) #566 and still unresolved; not touched here to avoid a second requiredness change in a PR that already carries one.Also verified clean (no change needed)
config/routes/shared_api.rbexactly, including the twodestroy_allcollection deletes and thewallet_codevscodenesting parameters.coupon_type,frequency,expiration) and applied-coupon enums (status,frequency) match the model constants;GET /couponscorrectly documents no filters, becauseCouponsController#indexpasses only pagination toCouponsQueryeven though the query class supportsstatusand a search term.alert_typeenums are correct as split: 5 subscription types on the subscription alert schemas, 4 wallet types on the wallet alert schemas, all 9 onTriggeredAlertObject— matchingAlert::SUBSCRIPTION_TYPES/WALLET_TYPESand the per-alertable rejection inCreateAlertService.directionis derived server-side and correctly absent from all alert inputs.subscription_statuson the subscription alert endpoints is real (Subscriptions::BaseController#find_subscription), not a phantom filter.MetadataObjectis["object","null"], so the{"metadata": null}body returned after a delete validates.Triaged from the docs guardian
Today's docs sweep (getlago/lago-doc#674) reported no suspected spec issues. The five leads from lago-doc#663 and #669 were re-verified as already landed on
main: the five wallet/payment-receipt webhooks and theTriggeredAlertObjectenum from #576, and theinvoice.add_on_addeddeprecation from #577. The remainingcredit_note.refund_failurenaming inconsistency is internal tolago-api(config/webhook_event_types.ymlvs the wirewebhook_type) and is not a spec issue.Feedback from previous guardian PRs
The four review items on #578 (approved 2026-09-09) were all resolved as "keep as is" and are recorded as standing conventions in the private skills repo; no spec change was required, and per that guidance the
resend_emailendpoint family is left to its dedicated cross-surface task rather than folded into this sweep.Process feedback for the retro
chat:write.conversations.historyandconversations.repliesboth fail withmissing_scope, so this run could not read thread replies on previous#documentationposts and may have missed Slack-delivered feedback. Announcing still works. Addingchannels:history(orgroups:history) would close the loop.Deferred to next run
src/files changed, well under the ~40-file cap.