feat: add rule-based internal accounts and the sweep failure webhook - #891
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. 2 Skipped Deployments
|
This stack of pull requests is managed by Graphite. Learn more about stacking. |
Greptile SummaryAdds the OpenAPI surface for creating rule-based internal accounts, exposes their sweep rules, and introduces
Confidence Score: 3/5The PR should not merge until the create-request required fields are aligned with the intended endpoint contract. The published schema currently accepts rule-based account requests without the mandatory routing rule while requiring a customer identifier that the stated endpoint design permits callers to omit. Files Needing Attention: openapi/components/schemas/customers/InternalAccountCreateRequest.yaml, openapi.yaml, mintlify/openapi.yaml
|
| Filename | Overview |
|---|---|
| openapi/components/schemas/customers/InternalAccountCreateRequest.yaml | Adds the creation payload, but its required-field list contradicts both the sweep-rule requirement and the stated optional-customer contract. |
| openapi/paths/internal_accounts.yaml | Adds the authenticated, idempotent create operation and documents its success and error responses. |
| openapi/components/schemas/customers/InternalAccount.yaml | Extends internal-account responses with an optional label and sweep rule. |
| openapi/components/schemas/webhooks/SweepFailure.yaml | Defines sweep-failure transaction, reason, outcome, and destination data without an established blocking defect. |
| openapi/webhooks/sweep.yaml | Registers and documents delivery, authentication, correlation, and deduplication semantics for SWEEP.FAILED. |
| openapi/openapi.yaml | Registers the new collection path and sweep webhook in the modular OpenAPI entry point. |
| openapi.yaml | Regenerated published bundle faithfully propagates the two create-request contract mismatches. |
| mintlify/openapi.yaml | Regenerated documentation bundle faithfully propagates the two create-request contract mismatches. |
Sequence Diagram
sequenceDiagram
participant Client
participant Grid
participant Destination
participant WebhookReceiver
Client->>Grid: POST /internal-accounts
Grid-->>Client: Rule-based internal account
Grid->>Destination: Forward settled incoming payment
alt Forward succeeds
Destination-->>Grid: Funds accepted
else Forward fails
Grid->>WebhookReceiver: SWEEP.FAILED
WebhookReceiver-->>Grid: 200 received
end
Prompt To Fix All With AI
### Issue 1
openapi/components/schemas/customers/InternalAccountCreateRequest.yaml:7-10
**Required sweep rule omitted**
When a generated client constructs a `RULE_BASED` request without `sweepRule`, the published schema accepts it even though this endpoint rejects the request with HTTP 400.
```suggestion
required:
- customerId
- type
- currency
- sweepRule
```
### Issue 2
openapi/components/schemas/customers/InternalAccountCreateRequest.yaml:7-10
**Optional customer marked required**
When a caller omits `customerId` as allowed by the endpoint design, generated clients reject the request locally because the schema makes that field mandatory.
```suggestion
required:
- type
- currency
```
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (1): Last reviewed commit: "feat: create internal accounts at POST /..." | Re-trigger Greptile
0c1fe9f to
577bb9e
Compare
✱ Stainless preview builds for gridThis PR will update the cli go kotlin openapi php python ruby typescript
|
|
⚡ Revision log — updated in place, latest first. Revision 7
Earlier revisions (6)Revision 6Four rounds of @shreyav's review, all narrowing the sweep vocabulary. Five reasons down to two.
What remains: Revision 5A failed sweep now fails the transaction, per @bsiaotickchong.
The Revision 4
Considered and rejected: folding the sweep reasons into Revision 3
Note on the Revision 2
Revision 1
|
|
⚡ Review ledger Round 1
Round 2Human review (@shreyav), 4 comments.
|
c639967 to
a55f42b
Compare
|
🦣 Congratulations @shreyav - your substantive review earned a Running hyena! (common)
View your Frost-dex: https://zeus.dev.dev.sparkinfra.net/#/dinodex/shreyav?section=ice-age |
The create verb belongs on the bare collection: the body carries an optional customerId, so a /customers/ path would describe a request that may name no customer. The customer and platform paths keep their GETs -- a listing has to pick a population, a create does not. The response also gains label and sweepRule, with the SweepRule and SweepRuleDestination schemas they need. Without them a rule-based account could not express its own rule in any response. Both are output-only, and the band is derived from the corridor at read time rather than stored, so what a platform reads is what the forward will enforce. There is deliberately no sweepRule.id -- the rule has no lifecycle apart from its account. Edited under openapi/ and rebuilt: the root openapi.yaml and the mintlify copy are bundler output, so editing those alone would have been reverted by the next build.
The handler rejects a RULE_BASED create with no sweepRule, but the schema marked it optional, so a generated client would construct a request the API always refuses. A rule-based account with no rule has no meaning -- the type and the rule are created together or not at all.
Two corrections to the rule-based account contract. The schemas described the movement as a "forward" throughout, which is not the word the product uses. Everything about a sweep now says sweep. Unrelated uses of the word elsewhere in the spec (documents, cards, SCA, cancellation, proxying) are left alone. `sendTransactionId` also described itself as the outgoing transaction created for the forward. It is the operation covering both the deposit into the rule-based account and the sweep out of it, so it says that now. `HELD_IN_RULE_BASED_ACCOUNT` is gone, and with it the `outcome` field. A sweep that cannot reach its destination always moves the funds to the customer's account in the same currency, so there was no second outcome to report and a single-valued field carried no information. `canonicalAccountId` becomes non-nullable and required, which is the stronger statement: a rule-based account never holds a balance. The description no longer promises a retry on the next deposit — there is no such mechanism. Co-Authored-By: bsiaotickchong <bsiaotickchong@users.noreply.github.com>
Moving creation to POST /internal-accounts was what made a rule-based account's owner open: it can be created for a customer or for the platform itself. The prose still assumed a customer throughout, which contradicts the endpoint it documents. The mechanism is now described in terms of the account holder — the routing rule, the canonical account funds land in when a sweep cannot complete, and the SWEEP.FAILED prose all read the same way whichever owns the account. customerId keeps its own note that it is required today, since platform-owned rule-based accounts are not available yet. That is a statement about what the endpoint currently accepts rather than about what a rule-based account is. Co-Authored-By: bsiaotickchong <bsiaotickchong@users.noreply.github.com>
The deposit into a rule-based account and the sweep out of it are legs of one operation, so incomingTransactionId and sendTransactionId named the same transaction. They collapse into a single required transactionId. It is created when the deposit settles — before the corridor bounds are evaluated — so it is always present, including on the paths that refuse the sweep before a quote exists. That makes it the dedup key for this at-least-once event as well as the value that correlates it with OUTGOING_PAYMENT.FAILED for the same sweep. Co-Authored-By: bsiaotickchong <bsiaotickchong@users.noreply.github.com>
A rule-based account can be owned by a customer or by the platform, which is what moving creation to POST /internal-accounts was for. Requiring customerId in the schema contradicted that and would have been a breaking change to relax once platform-owned accounts ship. The field stays documented as rejected-when-omitted today, so the schema describes the shape the endpoint will keep while the description carries the current restriction. Co-Authored-By: shreyav <shreyav@users.noreply.github.com>
SWEEP.FAILED existed because the earlier design made the sweep a separate transaction, so a sweep refused before it was attempted produced nothing for the platform to observe. The deposit and the sweep are now one operation, created as soon as the deposit settles, so the platform already receives a transaction for every case the webhook covered — a second event carrying the same id is redundant. The webhook, its SweepFailure payload, and the SWEEP.FAILED type are removed. What is not redundant moves onto the transaction itself: sweepFailureReason says why the destination was not paid, and sweepCanonicalAccountId says where the funds went instead. They are separate from failureReason because they are not a failure of the transaction. The deposit succeeds and the transaction completes; it is the onward sweep that was refused, and an integrator who sees only a completed deposit would otherwise have no way to tell BELOW_MINIMUM (the payer sent too little) from NO_ELIGIBLE_RAIL (the rule's destination is misconfigured). Co-Authored-By: bsiaotickchong <bsiaotickchong@users.noreply.github.com>
A payment swept out of a rule-based account is created when the deposit settles, which is before the sweep's quote locks a rate — so its receiving amount does not exist yet. OutgoingTransaction already allowed that: receivedAmount is not in its required set, alongside paymentRail, expectedSettlementAt and settlementTimelineSeconds, which are all documented as null before they resolve. IncomingTransaction required it, so the same state was inexpressible there. The two schemas are chosen by the transaction's destination, not its direction: an internal destination reads as incoming, an external one as outgoing. A rule's destination can be either, so both have to describe a sweep that has not been priced, and both now carry sweepFailureReason and sweepCanonicalAccountId for the same reason — a sweep can fail with either destination. Publishing an estimate instead was the alternative. Absence is the truthful state, and an amount that silently changes between the created and completed events is worse for reconciliation than one that is plainly not there yet. Co-Authored-By: bsiaotickchong <bsiaotickchong@users.noreply.github.com>
0f6e2af to
d170b60
Compare
|
sweepRule was in the required set while its own description said "Required when type is RULE_BASED" — the schema and the prose disagreed. RULE_BASED is the only type this endpoint creates today, so the two were equivalent in practice, but encoding it as unconditionally required means relaxing it later is a breaking change for a generated client. The coupling now lives in the description, which is where a per-type rule belongs. minimumAmount and maximumAmount are denominated in the rule-based account's own currency rather than the destination's. That was true of minimumAmount but buried mid-paragraph, and absent from maximumAmount entirely. Both now lead with it. Co-Authored-By: shreyav <shreyav@users.noreply.github.com>
The two were documented independently, leaving an integrator to work out whether a rule-based transaction could carry both and which to trust. It cannot. They describe different events and are mutually exclusive by construction: failureReason is set only when status is FAILED, and a diverted sweep completes — the deposit landed, only the onward movement was refused. Each field now says that, and points at the other, so reading either one is enough. Co-Authored-By: shreyav <shreyav@users.noreply.github.com>
A sweep that cannot reach the rule's destination is a failure of the transaction, so it belongs in failureReason rather than a field beside it. The five reasons join both failure enums as SWEEP_*, and the separate sweepFailureReason property and its SweepFailureReason schema are gone. This also removes the exclusivity rule the previous revision had to document — there is one field to read, and status FAILED means what it says. sweepCanonicalAccountId goes too, at the reviewer's request: a generic refund destination is coming that will cover where the funds went, for this and for other cases. The failure enums still say a rule-based account never holds a balance, so the funds move to the holder's own account in the same currency. receivedAmount now states the positive case first — always present, except on a sweep that was never priced. Co-Authored-By: shreyav <shreyav@users.noreply.github.com> Co-Authored-By: bsiaotickchong <bsiaotickchong@users.noreply.github.com>
Shorter than the version it replaces and says the same thing. Co-Authored-By: shreyav <shreyav@users.noreply.github.com>
SWEEP_EXECUTION_FAILED described the same event as QUOTE_EXECUTION_FAILED — a payment that was priced and then failed on the way to settlement. Sweeps report that one now, like any other payout, and both enums say so. SWEEP_BELOW_MINIMUM and SWEEP_ABOVE_MAXIMUM collapse into SWEEP_AMOUNT_OUT_OF_RANGE. Which edge was crossed is derivable from the rule's own minimumAmount and maximumAmount, so two members were carrying one bit that the caller can already read. Three reasons left, and each names something the others do not: the amount was outside the band, no rail could carry it, or Grid could not price it. RULE_BASED also joins the type-filter descriptions on the platform and agent internal-account paths, which shared the enum but not the documentation. Co-Authored-By: shreyav <shreyav@users.noreply.github.com>
SWEEP_NO_ELIGIBLE_RAIL fired when the rule named a rail the destination account does not support, which is what ACCOUNT_CANNOT_RECEIVE already describes — "the account exists but can't accept this payment". That entry now says a rail it cannot accept counts too, and the sweep-specific member is gone. It is dropped from the incoming enum outright rather than folded: rail validation returns early for anything that is not an external account, and an internal destination is the only kind that reads as an incoming transaction, so the member was unreachable there. Two sweep reasons remain, and the split is real: the amount was outside the corridor's band, or Grid could not price it. Co-Authored-By: shreyav <shreyav@users.noreply.github.com>
It said a failed sweep reports one of the SWEEP_* reasons, which stopped being true once SWEEP_EXECUTION_FAILED folded into QUOTE_EXECUTION_FAILED and SWEEP_NO_ELIGIBLE_RAIL into ACCOUNT_CANNOT_RECEIVE. A sweep can now fail with either of those, so the field is back to its original one-line description and the enum entries carry the detail. Co-Authored-By: shreyav <shreyav@users.noreply.github.com>

Summary
Adds the API surface for rule-based internal accounts — an additional account number for an existing customer with a routing rule attached, so incoming payments can be attributed to a specific payer and forwarded automatically.
Every schema change the feature needs is bundled here, in one reviewable PR, rather than arriving in pieces.
What's added
RULE_BASEDonInternalAccountType, plus the same value as atypefilter onGET /customers/internal-accounts.POST /internal-accounts— the create verb lives on the bare collection, not under/customers/: the body carries an optionalcustomerId, so a customer-scoped path would describe a request that may name no customer. The listing paths keep theirGETs, since a list has to pick a population and a create does not. The body takestypeandcurrency, an optionallabel, and asweepRuledescribing where funds are forwarded: adestination(account id plus an optional payment rail) and optionalpurposeOfPayment,description, andremittanceInformation.Idempotency-Keyis required, matching the other endpoints that mint something irreversible.Only
RULE_BASEDis creatable. The other account types are provisioned automatically when a customer is created or approved, so the endpoint rejects them with a specific message rather than a generic error.SWEEP.FAILEDwebhook — fired whenever a settled payment does not reach the rule's destination, including when the balance is below the corridor minimum and is returned to the payer instead. The payload carries both transaction ids, areason, and anoutcome, so an integrator can distinguish "this payment failed" from "and therefore this amount went somewhere else."Delivery is at-least-once and a redelivery carries a new event id, so the payload documents deduplicating on
incomingTransactionId.labelandsweepRuleon theInternalAccountresponse. A rule-based account can now show what it is: the label recorded at creation, and the rule itself — destination, the derivedminimumAmount/maximumAmountband, purpose, remittance and any fee override. Both are output-only, and the band is derived from the corridor at read time rather than stored, so what a platform reads is what the forward will actually enforce. There is deliberately nosweepRule.id: the rule has no lifecycle apart from its account, and publishing an id would invite a resource that does not exist.Two decisions worth a second opinion
DESTINATION_UNAVAILABLEis not included. It appeared in the original design, but nothing in the implementation can produce it — rail validation raises a single condition thatNO_ELIGIBLE_RAILalready covers. Publishing a value that never arrives costs a permanently un-removable enum member (adding one is non-breaking; removing one is not) and generates a dead case in every SDK. Adding it later, if a rail ever produces it, is free. Happy to reserve it if you'd rather.ABOVE_MAXIMUMis included and wasn't in the original design. A balance over the corridor ceiling would otherwise be submitted whole, rejected, and stranded; it now takes the same return path as any other non-success outcome, and this is how the platform is told.What's deliberately not here
GET /customers/internal-accounts/{id}andDELETEwere in the original design but are not implemented. Speccing them now would generate SDK methods that 405, so they're left for whenever the endpoints land.Verification
make lintpasses with 0 errors, and zero warnings or informational findings on any schema added here. All 1,971$refs resolve, none dangling.Everything is edited under
openapi/and the bundles regenerated withmake build—openapi.yamlandmintlify/openapi.yamlare output, so a source-only change would be reverted by the next build and a bundle-only change would be reverted just as silently. The route move adds a source path file (openapi/paths/internal_accounts.yaml) split out of the customers path, and the response fields add two source schemas (SweepRule.yaml,SweepRuleDestination.yaml).Original PR: #835