Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- Added deferred per-PaymentIntent outcome controls for one-time payment flows
through `metadata[billtap_payment_intent_outcome]`, local create aliases, and
`POST /api/payment_intents/{id}/outcome`.
- Hardened Stripe-like shape and validation for billing portal sessions and
customer payment-method lists, including portal flow enum checks,
PaymentMethod SDK fields, non-card filtering, and Stripe-style validation
Expand Down
4 changes: 2 additions & 2 deletions docs/COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ Base path: `/v1`
| Subscription schedules | `POST /v1/subscription_schedules`, `GET /v1/subscription_schedules`, `GET /v1/subscription_schedules/{id}`, `POST /v1/subscription_schedules/{id}`, `POST /v1/subscription_schedules/{id}/cancel`, `POST /v1/subscription_schedules/{id}/release` | Partial | Local schedule evidence for one due phase on an existing subscription. Test-clock advance applies a due phase by replacing subscription items and emits `customer.subscription.updated`; complex multi-phase billing, proration, invoices, and schedule lifecycle parity are not modeled. |
| Subscription items | `POST /v1/subscription_items`, `DELETE /v1/subscription_items/{id}` | Partial | Add or remove local subscription items for integration smoke paths. Billing proration and invoice recalculation are not modeled. |
| Invoices | `GET /v1/invoices`, `GET /v1/invoices/{id}`, `POST /v1/invoices/{id}/pay`, `POST /v1/invoices/create_preview` | Partial | List/retrieve invoices created by checkout. `pay` retries open local invoices with deterministic sandbox `payment_method` or `source` aliases, mutating invoice, subscription, payment-intent, timeline, and webhook evidence. Preview returns a zero-value local smoke-test invoice. Invoice create, finalize, send, void, collection, and dunning automation are not modeled. |
| Payment intents | `POST /v1/payment_intents`, `GET /v1/payment_intents`, `GET /v1/payment_intents/{id}`, `POST /v1/payment_intents/{id}/confirm`, `POST /v1/payment_intents/{id}/capture`, `POST /v1/payment_intents/{id}/cancel` | Partial | Create/list/retrieve and mutate local payment intents. `confirm` supports deterministic sandbox PaymentMethod aliases such as `pm_card_visa`, `pm_card_visa_chargeDeclined`, `pm_card_threeDSecure2Required`, `requires_action`, and local bank-transfer processing. Manual capture moves through `requires_capture` before `capture` succeeds. Billtap-specific action callback endpoints can complete or cancel a local SCA-required intent. This is a local state machine, not card processing or full PaymentIntent parameter parity. |
| Payment intents | `POST /v1/payment_intents`, `GET /v1/payment_intents`, `GET /v1/payment_intents/{id}`, `POST /v1/payment_intents/{id}/confirm`, `POST /v1/payment_intents/{id}/capture`, `POST /v1/payment_intents/{id}/cancel` | Partial | Create/list/retrieve and mutate local payment intents. `confirm` supports deterministic sandbox PaymentMethod aliases such as `pm_card_visa`, `pm_card_visa_chargeDeclined`, `pm_card_threeDSecure2Required`, `requires_action`, and local bank-transfer processing. Manual capture moves through `requires_capture` before `capture` succeeds. Direct one-time intents can store a deferred per-intent outcome with `metadata[billtap_payment_intent_outcome]`, `billtap_outcome`, or `deferred_outcome`, then apply it on `confirm`. Billtap-specific action callback endpoints can complete or cancel a local SCA-required intent. |
| Setup intents | `POST /v1/setup_intents`, `GET /v1/setup_intents`, `GET /v1/setup_intents/{id}`, `POST /v1/setup_intents/{id}/confirm`, `POST /v1/setup_intents/{id}/cancel` | Partial | Create/list/retrieve and mutate local setup intents with deterministic success, decline, and authentication-required aliases. Mandates, bank-account verification, and full SCA behavior are not modeled. |
| Payment methods | `GET /v1/payment_methods?customer={id}&type=card`, `GET /v1/customers/{id}/payment_methods?type=card` | Partial | Returns deterministic sandbox card PaymentMethod projections for known customers, including SDK-friendly `billing_details`, `card.checks`, `country`, `fingerprint`, `funding`, `networks`, `three_d_secure_usage`, `metadata`, and `redaction` fields. Query validation covers `type`, `allow_redisplay`, `limit`, and unknown parameters; non-card valid types return an empty local list. Customer fixtures can opt into empty or explicit payment-method lists for no-card and multi-card local scenarios. Create, attach, detach, and general update are not supported. |
| Customer cash balance | `GET /v1/customers/{id}/cash_balance`, `POST /v1/customers/{id}/cash_balance`, `GET /v1/customers/{id}/cash_balance_transactions`, `GET /v1/customers/{id}/cash_balance_transactions/{id}`, `POST /v1/test_helpers/customers/{id}/fund_cash_balance` | Partial | Local cash-balance evidence for bank-transfer smoke tests. The test-helper funding endpoint records a cash-balance transaction and settles processing bank-transfer PaymentIntents for that customer. Banking rails, reconciliation, currency conversion, and customer-balance invoice application are not modeled. |
Expand All @@ -126,7 +126,7 @@ Base path: `/api`
| Portal | `GET /api/portal`, `GET /api/portal/customers/{id}`, `POST /api/portal/subscriptions/{id}/plan-change`, `POST /api/portal/subscriptions/{id}/seat-change`, `POST /api/portal/subscriptions/{id}/cancel`, `POST /api/portal/subscriptions/{id}/resume`, `POST /api/portal/customers/{id}/payment-method` | Sandbox portal state and actions. These update local billing state and timeline evidence; they do not claim Stripe Billing Portal parity. |
| Dashboard evidence | `GET /api/objects`, `GET /api/timeline`, `GET /api/delivery-attempts`, `POST /api/debug-bundles` | Object lists, timelines, delivery evidence, and debug bundle data for local investigation. |
| Webhook operations | `POST /api/events/{id}/replay`, `POST /api/events/replay-group`, `POST /api/webhooks/endpoints/{id}/replay-historical` | Replays one event or a group of events and can schedule duplicate, delayed, out-of-order, signature-mismatch, simulated endpoint response, and fail-first-then-deliver attempts. Endpoint-scoped historical replay catches up matching events that were emitted before an app registered its webhook endpoint. |
| Payment intent actions | `POST /api/payment_intents/{id}/complete_action`, `POST /api/payment_intents/{id}/cancel_action` | Billtap-only callbacks used by hosted/client smoke tests to complete or abandon a local `requires_action` PaymentIntent. |
| Payment intent actions | `POST /api/payment_intents/{id}/complete_action`, `POST /api/payment_intents/{id}/cancel_action`, `POST /api/payment_intents/{id}/outcome` | Billtap-only callbacks used by hosted/client smoke tests to complete or abandon a local `requires_action` PaymentIntent, or to attach a deferred local outcome before confirming a one-time PaymentIntent. |
| Dispute simulation | `POST /api/disputes` | Billtap-only shortcut for creating local dispute evidence when an app does not already have a charge-like ID. |
| Fixtures | `POST /api/fixtures/apply`, `GET /api/fixtures/resolve`, `GET /api/fixtures/snapshot`, `POST /api/fixtures/assert` | Data-driven setup and assertion APIs for customers, products, prices, test clocks, subscription graphs, invoices, payment intents, refunds, credit notes, and timeline evidence. |
| Scenarios | `POST /api/scenarios/run` | Runs a scenario JSON object or YAML payload and returns the scenario report. |
Expand Down
38 changes: 38 additions & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -2048,13 +2048,15 @@ func (h *Handler) handlePaymentIntents(w http.ResponseWriter, r *http.Request) {
return
}
}
metadata := paymentIntentMetadata(p)
intent, err := h.billing.CreatePaymentIntent(r.Context(), billing.PaymentIntent{
ID: p.string("id"),
CustomerID: customerID,
Amount: p.int64("amount"),
Currency: p.string("currency"),
CaptureMethod: p.stringDefault("capture_method", "automatic"),
PaymentMethodID: p.string("payment_method"),
Metadata: metadata,
})
if err == nil {
h.emitPaymentIntentWebhook(r, "payment_intent.created", intent)
Expand Down Expand Up @@ -3337,6 +3339,17 @@ func (h *Handler) handlePaymentIntentAction(w http.ResponseWriter, r *http.Reque
if err == nil {
h.emitPaymentIntentWebhook(r, "payment_intent.canceled", intent)
}
case "outcome":
p, parseErr := parseParams(r)
if parseErr != nil {
writeError(w, http.StatusBadRequest, parseErr)
return
}
if validateErr := validatePaymentIntentOutcomeUpdate(p); validateErr != nil {
writeError(w, http.StatusBadRequest, validateErr)
return
}
intent, err = h.billing.SetPaymentIntentOutcome(r.Context(), id, p.string("outcome"))
default:
h.notFound(w, r)
return
Expand Down Expand Up @@ -3662,6 +3675,30 @@ func (p params) metadata() map[string]string {
return out
}

func paymentIntentMetadata(p params) map[string]string {
metadata := p.metadata()
outcome := firstNonEmptyString(
metadataValue(metadata, billing.MetadataPaymentIntentOutcome),
p.first("billtap_outcome", "deferred_outcome", "payment_intent_outcome"),
metadataValue(metadata, "billtap_outcome"),
)
if outcome == "" {
return metadata
}
if metadata == nil {
metadata = map[string]string{}
}
metadata[billing.MetadataPaymentIntentOutcome] = outcome
return metadata
}

func metadataValue(metadata map[string]string, key string) string {
if metadata == nil {
return ""
}
return strings.TrimSpace(metadata[key])
}

func (p params) list(key string) []string {
var out []string
for rawKey, value := range p.values {
Expand Down Expand Up @@ -4458,6 +4495,7 @@ func stripePaymentIntent(intent billing.PaymentIntent) map[string]any {
"status": intent.Status,
"capture_method": captureMethod,
"payment_method": emptyToNil(intent.PaymentMethodID),
"metadata": nonNilMap(intent.Metadata),
"last_payment_error": paymentIntentError(intent),
"next_action": paymentIntentNextAction(intent),
"client_secret": intent.ID + "_secret_billtap",
Expand Down
50 changes: 50 additions & 0 deletions internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,56 @@ func TestDirectPaymentIntentAndSetupIntentStateMachines(t *testing.T) {
t.Fatalf("canceled payment intent = %#v, want canceled", canceled)
}

deferredDecline := postForm[billing.PaymentIntent](t, handler, "/v1/payment_intents", url.Values{
"amount": {"2700"},
"currency": {"usd"},
"customer": {customer.ID},
"metadata[billtap_payment_intent_outcome]": {"card_declined"},
"metadata[fixture_ref]": {"deferred-decline"},
})
if deferredDecline.Status != "requires_payment_method" || deferredDecline.Metadata[billing.MetadataPaymentIntentOutcome] != "card_declined" {
t.Fatalf("deferred decline intent = %#v, want stored outcome metadata without confirmation", deferredDecline)
}
deferredDeclined := postForm[struct {
Status string `json:"status"`
PaymentMethod string `json:"payment_method"`
Metadata map[string]string `json:"metadata"`
LastPaymentError struct {
Code string `json:"code"`
DeclineCode string `json:"decline_code"`
} `json:"last_payment_error"`
}](t, handler, "/v1/payment_intents/"+deferredDecline.ID+"/confirm", url.Values{
"payment_method": {"pm_card_visa"},
})
if deferredDeclined.Status != "requires_payment_method" || deferredDeclined.Metadata[billing.MetadataPaymentIntentOutcome] != "card_declined" || deferredDeclined.LastPaymentError.Code != "card_declined" || deferredDeclined.LastPaymentError.DeclineCode != "generic_decline" {
t.Fatalf("deferred declined intent = %#v, want configured card_declined outcome on confirm", deferredDeclined)
}

deferredSCA := postForm[billing.PaymentIntent](t, handler, "/v1/payment_intents", url.Values{
"amount": {"3200"},
"currency": {"usd"},
"customer": {customer.ID},
})
configuredSCA := postJSON[struct {
Status string `json:"status"`
Metadata map[string]string `json:"metadata"`
}](t, handler, "/api/payment_intents/"+deferredSCA.ID+"/outcome", map[string]string{
"outcome": "requires_action",
})
if configuredSCA.Status != "requires_payment_method" || configuredSCA.Metadata[billing.MetadataPaymentIntentOutcome] != "requires_action" {
t.Fatalf("configured SCA intent = %#v, want deferred outcome metadata", configuredSCA)
}
confirmedSCA := postForm[struct {
Status string `json:"status"`
PaymentMethod string `json:"payment_method"`
NextAction map[string]any `json:"next_action"`
}](t, handler, "/v1/payment_intents/"+deferredSCA.ID+"/confirm", url.Values{
"payment_method": {"pm_card_visa"},
})
if confirmedSCA.Status != "requires_action" || confirmedSCA.PaymentMethod != "pm_card_visa" || confirmedSCA.NextAction["type"] != "use_stripe_sdk" {
t.Fatalf("confirmed SCA intent = %#v, want configured requires_action outcome", confirmedSCA)
}

setup := postForm[billing.SetupIntent](t, handler, "/v1/setup_intents", url.Values{
"customer": {customer.ID},
"payment_method": {"pm_card_visa"},
Expand Down
22 changes: 21 additions & 1 deletion internal/api/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,9 @@ func validatePaymentIntentCreate(p params) error {
"confirm",
"capture_method",
"outcome",
"billtap_outcome",
"deferred_outcome",
"payment_intent_outcome",
"description",
"receipt_email",
"setup_future_usage",
Expand All @@ -780,7 +783,7 @@ func validatePaymentIntentCreate(p params) error {
}); err != nil {
return err
}
if p.boolDefault("confirm", false) && !p.hasAny("payment_method", "outcome") {
if p.boolDefault("confirm", false) && !p.hasAny("payment_method", "outcome") && !hasPaymentIntentDeferredOutcome(p) {
return missingParam("payment_method")
}
return nil
Expand Down Expand Up @@ -871,6 +874,23 @@ func validatePaymentIntentCancel(p params) error {
})
}

func validatePaymentIntentOutcomeUpdate(p params) error {
return p.validate(paramSpec{
Allowed: []string{"outcome"},
Required: []string{"outcome"},
})
}

func hasPaymentIntentDeferredOutcome(p params) bool {
return p.hasAny(
"billtap_outcome",
"deferred_outcome",
"payment_intent_outcome",
"metadata["+billing.MetadataPaymentIntentOutcome+"]",
"metadata[billtap_outcome]",
)
}

func validateSetupIntentCreate(p params) error {
if err := p.validate(paramSpec{
Allowed: []string{
Expand Down
28 changes: 15 additions & 13 deletions internal/billing/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const (
MetadataDefaultPaymentMethod = "default_payment_method"
MetadataPaymentMethodsFixture = "billtap_payment_methods_fixture"
MetadataPaymentMethodIDs = "billtap_payment_method_ids"
MetadataPaymentIntentOutcome = "billtap_payment_intent_outcome"

PaymentMethodsFixtureEmpty = "empty"
PaymentMethodsFixtureExplicit = "explicit"
Expand Down Expand Up @@ -157,19 +158,20 @@ type ClockAdvanceResult struct {
}

type PaymentIntent struct {
ID string `json:"id"`
Object string `json:"object"`
CustomerID string `json:"customer"`
InvoiceID string `json:"invoice,omitempty"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
Status string `json:"status"`
CaptureMethod string `json:"capture_method,omitempty"`
FailureCode string `json:"failure_code,omitempty"`
DeclineCode string `json:"decline_code,omitempty"`
FailureMessage string `json:"failure_message,omitempty"`
PaymentMethodID string `json:"payment_method,omitempty"`
CreatedAt time.Time `json:"created_at"`
ID string `json:"id"`
Object string `json:"object"`
CustomerID string `json:"customer"`
InvoiceID string `json:"invoice,omitempty"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
Status string `json:"status"`
CaptureMethod string `json:"capture_method,omitempty"`
FailureCode string `json:"failure_code,omitempty"`
DeclineCode string `json:"decline_code,omitempty"`
FailureMessage string `json:"failure_message,omitempty"`
PaymentMethodID string `json:"payment_method,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
}

type SetupIntent struct {
Expand Down
Loading
Loading