feat: Introduce the split payments extension#409
Conversation
drewolson-google
left a comment
There was a problem hiding this comment.
A very straight-forward proposal, thanks @raginpirate. I left a few comments below, but the area I'm most unsure of is pre-defining of instrument groups.
|
|
||
| A set of instruments is valid if it matches **any** combination in the array. | ||
|
|
||
| #### Instrument Group |
There was a problem hiding this comment.
I'm struggling a bit to understand the need to be comprehensive with group definitions up-front. I worry you'll end up in one of two worlds:
- You attempt to be very specific with your configuration, which runs into an
N x Mproblem with many combinations. - You just have a single group with a min of 0 and a max of "some sufficiently large N" for each instrument type, effectively a free-for-all.
Might be worth talking through this tension live at TC.
There was a problem hiding this comment.
Thanks for the quick review Drew! Here's my POV:
- Up-front declaration is a requirement of the extension.
The dominant real-world pattern is narrow: "I support splitting a credit card with redeemables, and nothing else." That's a specific, declarative capability, and the platform needs it explicitly so it can render the right experiences. If a business doesn't declare combinations, the platform has to guess. The question isn't whether to declare, but how expressively.
- We shouldn't be dealing with an NxM problem for standard integrations, because of how the groups compose.
A group's types field is OR'd: one group can accept any of N instrument types. That collapses most of what would be N×M into a single combination. For example, "one primary instrument + up to 2 redeemables" with 4 primaries and 3 redeemable types is a single combination, not 12:
[
{ "types": ["card", "paypal", "apple_pay", "google_pay"], "min": 1, "max": 1 },
{ "types": ["gift_card", "store_credit", "loyalty"], "max": 2 }
]
Did you have a perspective of standard use cases this fails to keep concise?
Let me know if this was helpful! I'd be more than happy to chat live during a TC; sadly I will have to miss the one this Friday I think but we can always review during the next if this is not resolved async!
There was a problem hiding this comment.
I don't have a specific example other than worrying this will mostly devolve into people having a single "everything" entry in the most common cases.
One thought -- would it be easier to model this as a deny-list rather than an allow-list?
Totally open to discussion here, async is fine for sure.
There was a problem hiding this comment.
Greatly appreciate the push here Drew!
I think the deny-list pattern could be worse ergonomics for the ecosystem as businesses easily add new method types and fail to introduce a denied_combinations that should have existed, instead of properly exposing their method for different kind of applicable payment splits. This leads to poor UX as splits repeatedly get rejected only at complete time. I'll also note that we have no example capability leaning on a deny vs. allow list pattern, and that the combinatorial complexity of the solution does not change regardless of if we pick deny vs. allow.
At one point while writing this I did allow businesses to omit the allowed_combinations, which defaults to "everything works" if merchants wanted it; it was removed because it seems like a dangerous tool (like the pushback above), and somewhat impractical for a first deliverable (who in commerce supports splitting everything by anything?).
There was a problem hiding this comment.
Thanks for the reply. I think we can take a "wait and see" approach on omitting allowed_combinations -- if a lot of folks are effectively doing it then we can make it easier. If not, no need.
drewolson-google
left a comment
There was a problem hiding this comment.
A very straight-forward proposal, thanks @raginpirate. I left a few comments below, but the area I'm most unsure of is pre-defining of instrument groups.
|
Great work on this, @raginpirate. The capability-gated pattern is clean, allowed_combinations is a solid primitive, and the open-amount model maps naturally to how redeemables work in practice (as @ihoosain noted, Etsy's gift card flow fits this well). A few thoughts on scope and failure handling that I think would make this more robust: Scoping to redeemables + fallback Would it make sense to explicitly scope this version to redeemable + open-loop fallback? In my experience, the natural processing order is open-loop first: you authorize the card (the uncertain, externally-controlled leg), and only then debit the redeemable (which the business platform owns and can guarantee). If the card fails, nothing has been touched. This avoids the reversal problem entirely for the common case, keeps the complexity bounded, and gives us room to tackle the harder multi-network scenarios in a follow-up once we have answers for partial auth and dispute routing. Relaxing the atomicity requirement
IMO the protocol should require the business to report per-instrument status (succeeded, failed, pending) so the platform has visibility, but leave the failure policy to the business. A SHOULD default of void-all is reasonable, but a MUST removes agency from the party best positioned to make that call. Something like a failure_policy field in the business config ("void_all", "partial_allowed", "escalate") would let businesses declare their behavior without the protocol dictating it. Processing order as a spec concern Excited to see this move forward. Happy to discuss further |
|
Thanks as well for your quick feedback @prasad-stripe! Great thoughts, let me clarify and I also just pushed up a fix based on your feedback ❤️
I think the advertised capability enables a business who cannot abstract these concerns from the platform to not support anything but redeemables. I also am of the opinion that all of these concerns can be abstracted, and is only a concern to empower businesses who wants to do some potentially smarter things, some of which you touch on in your next paragraph...
Lets dig into this!
I agree theres tension here... Thank you for the pushback! I think this is a disconnect between my intention vs. how I wrote this. Although I believe we should dictate this as a MUST, it is not a MUST IN REALTIME; the effect after a batch of UCP checkout submissions needs to behave this way, but it does not have to happen immediately. Let me take a stab at improving that phrasing! With that, I think the above perspective might change? Hopefully we can see we're not mandating the payment processing layer, but mandating the side-effect of abandoned charges is NOT ok. And that businesses SHOULD have the flexibility to do any smart caching they need to 😄
Great thinking, I definitely pondered this as well!
This feature is pretty key to the spec though; when you submit N instruments without an amount as a platform, the order you submit them in determines how much they would be charged. We'd go from allowing a customer at our register to say "charge gift card A, then B, then my card for the rest", to only say "charge these (gift card A, B, and card), I don't care how". Greatly appreciate your input here and PTAL at my change to the MUST; does this move the needle for you or lmk some examples this is still problematic for! |
|
Thanks @raginpirate, really appreciate the thoughtful response and the quick update. The eventual-consistency framing lands well for me. "Buyer MUST NOT remain charged for an incomplete split after checkout" is the right invariant, and allowing async reversal with retry/rate-limiter flexibility is exactly what I was hoping for. I'm aligned on allocation priority as buyer intent (charge A first, then B, then card for the rest) being distinct from processing order. That separation makes sense. One thing I'd like to push on: the re-submit flow. Your updated language mentions "wait and see if the buyer re-submits partially captured instruments," which is great, but from the platform's perspective there's no structured signal to act on. Today the only response is payment_failed in messages, which doesn't distinguish between:
For the second case, the platform (or agent) needs to know: which instruments succeeded, which failed, and what amount is still outstanding. Without that, the agent can't prompt the buyer with "your card was declined, you still owe $45, want to try another?" My suggestion: on a partial failure where the business is holding the checkout open, return per-instrument status (succeeded/failed/pending) and an amount_pending field indicating what's left to cover. This gives the platform everything it needs to drive the recovery flow without the protocol dictating how the business handles it internally. And importantly, the consumer should be able to cancel the open checkout at any point, which would trigger reversal of any instruments that already succeeded in earlier stages. I think there's real value in addressing this alongside this PR since split payments without a structured recovery path will push every implementer to invent their own. But if you'd rather keep this PR focused and track it as an immediate follow-up, I'm OK with that too as long as it's sequenced before this extension moves beyond working draft. Thanks again for the collaboration here, this is shaping up nicely. |
|
Great discussion on the recovery flow, @prasad-stripe and @raginpirate. I think there's a low-complexity path to structured per-instrument status that doesn't require new schema fields. +1 on the importance of platforms being able to convey per-instrument status to buyers — without it, the platform can't guide the user on what specifically needs to change to complete the transaction. The existing message schemas already have a {
"status": "incomplete",
"payment": {
"instruments": [
{ "id": "pi_gc_1", "type": "gift_card", "amount": 1000, "..." : "..." },
{ "id": "pi_card_1", "type": "card", "..." : "..." }
]
},
"messages": [
{
"type": "info",
"path": "$.payment.instruments[0]",
"content": "Gift card authorized for $10.00"
},
{
"type": "error",
"code": "payment_failed",
"path": "$.payment.instruments[1]",
"content": "Card declined — insufficient funds",
"severity": "recoverable"
}
]
}The platform can reconstruct the full picture from what's already in the response:
This is purely a spec-language clarification — no schema changes needed. It also pairs well with the updated eventual-consistency language: the business holds the checkout open, returns |
|
That’s exactly how I viewed it as well @jamesandersen! I think messages and paths work perfectly and I should have had more intentional examples of how to form those messages in the proposal. Let me see what I can push to the PR later today and I’ll tag folks again when I do! 😄 |
3b04598 to
77c26d4
Compare
|
Just pushed an update covering the open feedback from this round: For @jamesandersen & @prasad-stripe — implemented the per-instrument status approach clarified above, pulling inspiration from the discounts capability! The Error Handling section now requires a payment_failed error per failed instrument with path pointing at the instrument, and Thanks all for the thoughtful review — this is much sharper than what I originally opened! |
Description
Adds the
dev.ucp.shopping.split_paymentsextension, enabling buyers to pay with multiple payment instruments in a single checkout. Businesses declare which instrument combinations they support viaallowed_combinations(a constraint language built on instrument groups with type/min/max). Instruments are submitted in allocation priority order, with each contribution either platform-specified (amountset) or business-determined (amountomitted, e.g. gift card balance).This unlocks the development of redeemable payment instruments, which are functionally incomplete without the support of split payments to cover their remaining balance.
Category (Required)
ucp-schematool (resolver, linter, validator). (Requires Maintainer approval)Checklist
^ WIP for the python_sdk commit.