From 1ed2f740d179c877fc0a988f8a9cb7c7044a05f6 Mon Sep 17 00:00:00 2001 From: midagedev Date: Tue, 12 May 2026 06:29:12 +0900 Subject: [PATCH] Support deferred PaymentIntent outcomes --- CHANGELOG.md | 3 ++ docs/COMPATIBILITY.md | 4 +- internal/api/api.go | 38 ++++++++++++++ internal/api/api_test.go | 50 +++++++++++++++++++ internal/api/validation.go | 22 +++++++- internal/billing/models.go | 28 ++++++----- internal/billing/service.go | 46 +++++++++++++++-- internal/storage/billing.go | 33 ++++++------ .../013_payment_intent_metadata.sql | 1 + internal/storage/storage_test.go | 13 +++-- specs/000-product/contracts/api.md | 22 +++++++- 11 files changed, 218 insertions(+), 42 deletions(-) create mode 100644 internal/storage/migrations/013_payment_intent_metadata.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bac260..d4f6296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index b6b858f..2f27fa8 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -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. | @@ -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. | diff --git a/internal/api/api.go b/internal/api/api.go index 35c4bed..de64059 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -2048,6 +2048,7 @@ 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, @@ -2055,6 +2056,7 @@ func (h *Handler) handlePaymentIntents(w http.ResponseWriter, r *http.Request) { 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) @@ -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 @@ -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 { @@ -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", diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 44a7fd3..606ad5d 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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"}, diff --git a/internal/api/validation.go b/internal/api/validation.go index e68eead..c982de2 100644 --- a/internal/api/validation.go +++ b/internal/api/validation.go @@ -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", @@ -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 @@ -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{ diff --git a/internal/billing/models.go b/internal/billing/models.go index 4aac8fb..e4e34a5 100644 --- a/internal/billing/models.go +++ b/internal/billing/models.go @@ -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" @@ -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 { diff --git a/internal/billing/service.go b/internal/billing/service.go index 2676c43..5cb9782 100644 --- a/internal/billing/service.go +++ b/internal/billing/service.go @@ -914,6 +914,11 @@ func (s *Service) CreatePaymentIntent(ctx context.Context, in PaymentIntent) (Pa in.Currency = strings.ToLower(strings.TrimSpace(in.Currency)) in.CaptureMethod = firstNonEmpty(in.CaptureMethod, "automatic") in.Status = firstNonEmpty(in.Status, "requires_payment_method") + if outcome := paymentIntentConfiguredOutcome(in.Metadata); outcome != "" { + if _, ok := intentOutcomeSpec(outcome); !ok { + return PaymentIntent{}, fmt.Errorf("%w: %s", ErrUnsupportedOutcome, outcome) + } + } in.CreatedAt = now created, err := s.repo.CreatePaymentIntent(ctx, in) if err != nil { @@ -930,15 +935,17 @@ func (s *Service) ConfirmPaymentIntent(ctx context.Context, id string, paymentMe if err := ensurePaymentIntentConfirmable(intent); err != nil { return PaymentIntent{}, err } - if firstNonEmpty(paymentMethodID, intent.PaymentMethodID, outcome) == "" { + configuredOutcome := paymentIntentConfiguredOutcome(intent.Metadata) + effectiveOutcome := firstNonEmpty(outcome, configuredOutcome) + if firstNonEmpty(paymentMethodID, intent.PaymentMethodID, effectiveOutcome) == "" { return PaymentIntent{}, fmt.Errorf("%w: payment_method is required", ErrInvalidInput) } if paymentMethodID != "" { intent.PaymentMethodID = paymentMethodID } - spec, ok := intentOutcomeSpec(firstNonEmpty(outcome, intent.PaymentMethodID)) + spec, ok := intentOutcomeSpec(firstNonEmpty(effectiveOutcome, intent.PaymentMethodID)) if !ok { - return PaymentIntent{}, fmt.Errorf("%w: %s", ErrUnsupportedOutcome, outcome) + return PaymentIntent{}, fmt.Errorf("%w: %s", ErrUnsupportedOutcome, effectiveOutcome) } intent.PaymentMethodID = firstNonEmpty(intent.PaymentMethodID, spec.PaymentMethodID) intent.Status = spec.PaymentIntentStatus @@ -956,7 +963,29 @@ func (s *Service) ConfirmPaymentIntent(ctx context.Context, id string, paymentMe } now := s.now() return s.repo.UpdatePaymentIntent(ctx, intent, []TimelineEntry{ - timelineEntry("pi_"+intent.ID+"_confirmed_"+now.Format(time.RFC3339Nano), paymentIntentEvent(intent.Status), "Payment intent "+intent.Status, ObjectPaymentIntent, intent.ID, intent.CustomerID, "", "", intent.ID, map[string]string{"status": intent.Status}, now), + timelineEntry("pi_"+intent.ID+"_confirmed_"+now.Format(time.RFC3339Nano), paymentIntentEvent(intent.Status), "Payment intent "+intent.Status, ObjectPaymentIntent, intent.ID, intent.CustomerID, "", "", intent.ID, map[string]string{"status": intent.Status, "outcome": firstNonEmpty(effectiveOutcome, intent.PaymentMethodID)}, now), + }) +} + +func (s *Service) SetPaymentIntentOutcome(ctx context.Context, id string, outcome string) (PaymentIntent, error) { + outcome = strings.TrimSpace(outcome) + if outcome == "" { + return PaymentIntent{}, fmt.Errorf("%w: outcome is required", ErrInvalidInput) + } + if _, ok := intentOutcomeSpec(outcome); !ok { + return PaymentIntent{}, fmt.Errorf("%w: %s", ErrUnsupportedOutcome, outcome) + } + intent, err := s.repo.GetPaymentIntent(ctx, id) + if err != nil { + return PaymentIntent{}, err + } + if intent.Metadata == nil { + intent.Metadata = map[string]string{} + } + intent.Metadata[MetadataPaymentIntentOutcome] = outcome + now := s.now() + return s.repo.UpdatePaymentIntent(ctx, intent, []TimelineEntry{ + timelineEntry("pi_"+intent.ID+"_outcome_"+now.Format(time.RFC3339Nano), "payment_intent.outcome_configured", "Payment intent outcome configured", ObjectPaymentIntent, intent.ID, intent.CustomerID, "", "", intent.ID, map[string]string{"outcome": outcome}, now), }) } @@ -1982,6 +2011,15 @@ func intentOutcomeSpec(outcome string) (checkoutOutcomeSpec, bool) { return checkoutOutcomeSpec{}, false } +func paymentIntentConfiguredOutcome(metadata map[string]string) string { + for _, key := range []string{MetadataPaymentIntentOutcome, "billtap_outcome"} { + if value := strings.TrimSpace(metadata[key]); value != "" { + return value + } + } + return "" +} + func paymentIntentEvent(status string) string { switch status { case "succeeded": diff --git a/internal/storage/billing.go b/internal/storage/billing.go index eac36f7..2aabd9c 100644 --- a/internal/storage/billing.go +++ b/internal/storage/billing.go @@ -472,9 +472,9 @@ func (s *SQLiteStore) RecordCheckoutCompletion(ctx context.Context, c billing.Ch c.Invoice.ID, c.Invoice.CustomerID, c.Invoice.SubscriptionID, c.Invoice.Status, c.Invoice.Currency, c.Invoice.Subtotal, c.Invoice.Total, c.Invoice.AmountDue, c.Invoice.AmountPaid, c.Invoice.AttemptCount, encodeOptionalTime(c.Invoice.NextPaymentAttempt), c.Invoice.PaymentIntentID, encodeTime(c.Invoice.CreatedAt)); err != nil { return billing.CheckoutSession{}, err } - if _, err := tx.ExecContext(ctx, `INSERT INTO payment_intents (id, customer_id, invoice_id, amount, currency, status, failure_code, failure_decline_code, failure_message, payment_method_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - c.PaymentIntent.ID, c.PaymentIntent.CustomerID, c.PaymentIntent.InvoiceID, c.PaymentIntent.Amount, c.PaymentIntent.Currency, c.PaymentIntent.Status, c.PaymentIntent.FailureCode, c.PaymentIntent.DeclineCode, c.PaymentIntent.FailureMessage, c.PaymentIntent.PaymentMethodID, encodeTime(c.PaymentIntent.CreatedAt)); err != nil { + if _, err := tx.ExecContext(ctx, `INSERT INTO payment_intents (id, customer_id, invoice_id, amount, currency, status, failure_code, failure_decline_code, failure_message, payment_method_id, metadata, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + c.PaymentIntent.ID, c.PaymentIntent.CustomerID, c.PaymentIntent.InvoiceID, c.PaymentIntent.Amount, c.PaymentIntent.Currency, c.PaymentIntent.Status, c.PaymentIntent.FailureCode, c.PaymentIntent.DeclineCode, c.PaymentIntent.FailureMessage, c.PaymentIntent.PaymentMethodID, encodeMap(c.PaymentIntent.Metadata), encodeTime(c.PaymentIntent.CreatedAt)); err != nil { return billing.CheckoutSession{}, err } if _, err := tx.ExecContext(ctx, `UPDATE checkout_sessions @@ -636,9 +636,9 @@ func updateOpenInvoicePaymentTx(ctx context.Context, tx *sql.Tx, invoice billing func updatePaymentIntentTx(ctx context.Context, tx *sql.Tx, pi billing.PaymentIntent) error { result, err := tx.ExecContext(ctx, `UPDATE payment_intents - SET customer_id = ?, invoice_id = ?, amount = ?, currency = ?, status = ?, capture_method = ?, failure_code = ?, failure_decline_code = ?, failure_message = ?, payment_method_id = ? + SET customer_id = ?, invoice_id = ?, amount = ?, currency = ?, status = ?, capture_method = ?, failure_code = ?, failure_decline_code = ?, failure_message = ?, payment_method_id = ?, metadata = ? WHERE id = ?`, - encodeOptionalString(pi.CustomerID), encodeOptionalString(pi.InvoiceID), pi.Amount, pi.Currency, pi.Status, pi.CaptureMethod, pi.FailureCode, pi.DeclineCode, pi.FailureMessage, pi.PaymentMethodID, pi.ID) + encodeOptionalString(pi.CustomerID), encodeOptionalString(pi.InvoiceID), pi.Amount, pi.Currency, pi.Status, pi.CaptureMethod, pi.FailureCode, pi.DeclineCode, pi.FailureMessage, pi.PaymentMethodID, encodeMap(pi.Metadata), pi.ID) if err != nil { return err } @@ -760,9 +760,9 @@ func (s *SQLiteStore) RecordSubscriptionRenewal(ctx context.Context, sub billing invoice.ID, invoice.CustomerID, invoice.SubscriptionID, invoice.Status, invoice.Currency, invoice.Subtotal, invoice.Total, invoice.AmountDue, invoice.AmountPaid, invoice.AttemptCount, encodeOptionalTime(invoice.NextPaymentAttempt), invoice.PaymentIntentID, encodeTime(invoice.CreatedAt)); err != nil { return billing.Subscription{}, billing.Invoice{}, billing.PaymentIntent{}, err } - if _, err := tx.ExecContext(ctx, `INSERT INTO payment_intents (id, customer_id, invoice_id, amount, currency, status, capture_method, failure_code, failure_decline_code, failure_message, payment_method_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - pi.ID, encodeOptionalString(pi.CustomerID), encodeOptionalString(pi.InvoiceID), pi.Amount, pi.Currency, pi.Status, pi.CaptureMethod, pi.FailureCode, pi.DeclineCode, pi.FailureMessage, pi.PaymentMethodID, encodeTime(pi.CreatedAt)); err != nil { + if _, err := tx.ExecContext(ctx, `INSERT INTO payment_intents (id, customer_id, invoice_id, amount, currency, status, capture_method, failure_code, failure_decline_code, failure_message, payment_method_id, metadata, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + pi.ID, encodeOptionalString(pi.CustomerID), encodeOptionalString(pi.InvoiceID), pi.Amount, pi.Currency, pi.Status, pi.CaptureMethod, pi.FailureCode, pi.DeclineCode, pi.FailureMessage, pi.PaymentMethodID, encodeMap(pi.Metadata), encodeTime(pi.CreatedAt)); err != nil { return billing.Subscription{}, billing.Invoice{}, billing.PaymentIntent{}, err } for _, entry := range timeline { @@ -789,16 +789,16 @@ func (s *SQLiteStore) RecordSubscriptionRenewal(ctx context.Context, sub billing } func (s *SQLiteStore) CreatePaymentIntent(ctx context.Context, pi billing.PaymentIntent) (billing.PaymentIntent, error) { - if _, err := s.db.ExecContext(ctx, `INSERT INTO payment_intents (id, customer_id, invoice_id, amount, currency, status, capture_method, failure_code, failure_decline_code, failure_message, payment_method_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - pi.ID, encodeOptionalString(pi.CustomerID), encodeOptionalString(pi.InvoiceID), pi.Amount, pi.Currency, pi.Status, pi.CaptureMethod, pi.FailureCode, pi.DeclineCode, pi.FailureMessage, pi.PaymentMethodID, encodeTime(pi.CreatedAt)); err != nil { + if _, err := s.db.ExecContext(ctx, `INSERT INTO payment_intents (id, customer_id, invoice_id, amount, currency, status, capture_method, failure_code, failure_decline_code, failure_message, payment_method_id, metadata, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + pi.ID, encodeOptionalString(pi.CustomerID), encodeOptionalString(pi.InvoiceID), pi.Amount, pi.Currency, pi.Status, pi.CaptureMethod, pi.FailureCode, pi.DeclineCode, pi.FailureMessage, pi.PaymentMethodID, encodeMap(pi.Metadata), encodeTime(pi.CreatedAt)); err != nil { return billing.PaymentIntent{}, err } return s.GetPaymentIntent(ctx, pi.ID) } func (s *SQLiteStore) GetPaymentIntent(ctx context.Context, id string) (billing.PaymentIntent, error) { - row := s.db.QueryRowContext(ctx, `SELECT id, customer_id, invoice_id, amount, currency, status, capture_method, failure_code, failure_decline_code, failure_message, payment_method_id, created_at FROM payment_intents WHERE id = ?`, id) + row := s.db.QueryRowContext(ctx, `SELECT id, customer_id, invoice_id, amount, currency, status, capture_method, failure_code, failure_decline_code, failure_message, payment_method_id, metadata, created_at FROM payment_intents WHERE id = ?`, id) pi, err := scanPaymentIntent(row) if errors.Is(err, sql.ErrNoRows) { return billing.PaymentIntent{}, billing.ErrNotFound @@ -828,7 +828,7 @@ func (s *SQLiteStore) UpdatePaymentIntent(ctx context.Context, pi billing.Paymen } func (s *SQLiteStore) ListPaymentIntents(ctx context.Context) ([]billing.PaymentIntent, error) { - rows, err := s.db.QueryContext(ctx, `SELECT id, customer_id, invoice_id, amount, currency, status, capture_method, failure_code, failure_decline_code, failure_message, payment_method_id, created_at FROM payment_intents ORDER BY created_at DESC, id DESC`) + rows, err := s.db.QueryContext(ctx, `SELECT id, customer_id, invoice_id, amount, currency, status, capture_method, failure_code, failure_decline_code, failure_message, payment_method_id, metadata, created_at FROM payment_intents ORDER BY created_at DESC, id DESC`) if err != nil { return nil, err } @@ -862,7 +862,7 @@ func (s *SQLiteStore) ListPaymentIntentsFiltered(ctx context.Context, filter bil } clauses = append(clauses, "invoice_id IN ("+strings.Join(placeholders, ",")+")") } - rows, err := s.db.QueryContext(ctx, `SELECT id, customer_id, invoice_id, amount, currency, status, capture_method, failure_code, failure_decline_code, failure_message, payment_method_id, created_at + rows, err := s.db.QueryContext(ctx, `SELECT id, customer_id, invoice_id, amount, currency, status, capture_method, failure_code, failure_decline_code, failure_message, payment_method_id, metadata, created_at FROM payment_intents WHERE `+strings.Join(clauses, " AND ")+` ORDER BY created_at DESC, id DESC`, args...) if err != nil { return nil, err @@ -1306,14 +1306,15 @@ func scanInvoice(row scanner) (billing.Invoice, error) { func scanPaymentIntent(row scanner) (billing.PaymentIntent, error) { var pi billing.PaymentIntent - var createdAt string + var createdAt, metadataRaw string var customerID, invoiceID sql.NullString - if err := row.Scan(&pi.ID, &customerID, &invoiceID, &pi.Amount, &pi.Currency, &pi.Status, &pi.CaptureMethod, &pi.FailureCode, &pi.DeclineCode, &pi.FailureMessage, &pi.PaymentMethodID, &createdAt); err != nil { + if err := row.Scan(&pi.ID, &customerID, &invoiceID, &pi.Amount, &pi.Currency, &pi.Status, &pi.CaptureMethod, &pi.FailureCode, &pi.DeclineCode, &pi.FailureMessage, &pi.PaymentMethodID, &metadataRaw, &createdAt); err != nil { return pi, err } pi.Object = billing.ObjectPaymentIntent pi.CustomerID = customerID.String pi.InvoiceID = invoiceID.String + pi.Metadata = decodeMap(metadataRaw) pi.CreatedAt = decodeTime(createdAt) return pi, nil } diff --git a/internal/storage/migrations/013_payment_intent_metadata.sql b/internal/storage/migrations/013_payment_intent_metadata.sql new file mode 100644 index 0000000..3c7914d --- /dev/null +++ b/internal/storage/migrations/013_payment_intent_metadata.sql @@ -0,0 +1 @@ +ALTER TABLE payment_intents ADD COLUMN metadata TEXT NOT NULL DEFAULT '{}'; diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index ad2c610..892fc0c 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -22,8 +22,8 @@ func TestSQLiteMigrationsRun(t *testing.T) { if err != nil { t.Fatalf("MigrationVersions returned error: %v", err) } - if len(versions) != 12 || versions[0] != 1 || versions[1] != 2 || versions[2] != 3 || versions[3] != 4 || versions[4] != 5 || versions[5] != 6 || versions[6] != 7 || versions[7] != 8 || versions[8] != 9 || versions[9] != 10 || versions[10] != 11 || versions[11] != 12 { - t.Fatalf("versions = %#v, want [1 2 3 4 5 6 7 8 9 10 11 12]", versions) + if len(versions) != 13 || versions[0] != 1 || versions[1] != 2 || versions[2] != 3 || versions[3] != 4 || versions[4] != 5 || versions[5] != 6 || versions[6] != 7 || versions[7] != 8 || versions[8] != 9 || versions[9] != 10 || versions[10] != 11 || versions[11] != 12 || versions[12] != 13 { + t.Fatalf("versions = %#v, want [1 2 3 4 5 6 7 8 9 10 11 12 13]", versions) } } @@ -52,16 +52,21 @@ func TestDirectIntentSchemaAllowsOptionalCustomerAndPreservesForeignKeys(t *test } defer store.Close() - if _, err := store.CreatePaymentIntent(ctx, billing.PaymentIntent{ + created, err := store.CreatePaymentIntent(ctx, billing.PaymentIntent{ ID: "pi_direct_no_customer", Amount: 1000, Currency: "usd", Status: "requires_payment_method", CaptureMethod: "automatic", + Metadata: map[string]string{billing.MetadataPaymentIntentOutcome: "card_declined"}, CreatedAt: time.Now().UTC(), - }); err != nil { + }) + if err != nil { t.Fatalf("CreatePaymentIntent without customer returned error: %v", err) } + if created.Metadata[billing.MetadataPaymentIntentOutcome] != "card_declined" { + t.Fatalf("created payment intent metadata = %#v, want deferred outcome metadata", created.Metadata) + } if _, err := store.CreatePaymentIntent(ctx, billing.PaymentIntent{ ID: "pi_direct_bad_customer", diff --git a/specs/000-product/contracts/api.md b/specs/000-product/contracts/api.md index b40ade5..659ee65 100644 --- a/specs/000-product/contracts/api.md +++ b/specs/000-product/contracts/api.md @@ -130,8 +130,10 @@ deterministic sandbox aliases, manual capture, cancel, timeline evidence, and local webhook events. `requires_action` returns a local `next_action.use_stripe_sdk` shape that can be completed or canceled through Billtap action callbacks, and local bank-transfer intents can move from -`processing` to `succeeded` when customer cash balance is funded. They do not -process real cards or claim full Stripe PaymentIntent parameter parity. +`processing` to `succeeded` when customer cash balance is funded. One-time +PaymentIntents can store a deferred per-intent outcome with +`metadata[billtap_payment_intent_outcome]`, `billtap_outcome`, or +`deferred_outcome`; the stored outcome is applied when the intent is confirmed. ### Setup Intents @@ -360,6 +362,22 @@ Completes a local `requires_action` PaymentIntent. Cancels a local `requires_action` PaymentIntent. +### `POST /api/payment_intents/{id}/outcome` + +Stores a local deferred PaymentIntent outcome before confirmation. + +Request: + +```json +{ + "outcome": "requires_action" +} +``` + +The outcome uses the same deterministic aliases accepted by direct +PaymentIntent confirmation, including `payment_succeeded`, `card_declined`, +`requires_action`, `payment_pending`, `bank_transfer`, and `canceled`. + ### `POST /api/disputes` Creates local dispute evidence when a test does not already have a charge-like