Stop a Stripe create Stripe answered with its own 500, and look it up before sending it again
#312. The Stripe outbox retried a product or price create that Stripe answered with its own server error for a day, under an idempotency key Stripe answers from its saved 500 every time, so the product could not be sold and the readiness panel said the price was syncing all day. After the dead-letter, Retry put the same row back and sent the same key again. A 500 does not say whether Stripe created the object, so a new key alone could create a second one. A keyed create (product, price, customer) that Stripe answers with its own error object at 500 or above is now dead-lettered after that one attempt, with the reason "Stripe answered with a server error; whether it created the price is unknown" (product, customer). A gateway's error page, which carries no Stripe error object, and a request that gets no answer stay retried. reconcile_subscription, which sends no key of its own, keeps retrying. Retry counts each requeue (migration 00039: core.outbox.requeues), and a requeued entry's key gains the count, so its create is a new request. Before that create, a requeued product or price entry lists what Stripe made since an hour before the entry was written, through Stripe's list rather than its lagging search, and maps the oldest object whose metadata carries the console's id (a price also with the same amount, currency, compared without case, and interval, and on the same product); it creates only when none matches, and logs the other matches. An entry never requeued keeps its key. The three creates carry the attempt's context, so none outlives its attempt beside a Retry. Copy, approved: the panel's failed-sync row ends at the reason with a full stop, without "Use Retry to run it again."; the line under the control is gone; the control reads "Find or create in Stripe" in place of "Retry Stripe sync". The reader-columns golden lists the new column, and the dead-lettered sync golden loses the two removed lines. docs/stripe.md and docs/models/payments-billing.md say what the outbox does. Filed from the work: #314 (Stripe's event for an object made after a 500 is never recorded) and #315 (a checkout can leave a second Stripe customer); the panel's share of #149 and the public-details reads' share of #313 go there as comments. Deploy: no data change. Before it, a read-only count of the Stripe create entries by status; after it, an operator presses Find or create in Stripe for each product whose panel shows a failed sync. A deployment using the console_reader role re-runs scripts/db-reader-role.sql as the schema owner. Break list: 52 records, 52 CAUGHT. Named without a record: the migration's default (a break of it fails every database test at the migration, before any test's claim) and the comments of items 1.2, 2.2 and 2.5. Full suite on reset databases: 5652 passed, 16 skipped, none failed. Screens: the product detail shots lose the two removed lines; the other changed shots are timestamps and regenerated ids, as before.
This commit is contained in:
@@ -36,7 +36,7 @@ Database objects sit in the `core` schema (defined in `internal/db/migrations/00
|
||||
| Webhook processor | One Temporal workflow per stored event, keyed by Stripe's event id and started by the receiver: subscription and checkout events trigger the reconciler; `invoice.finalized`, `invoice.paid`, `invoice.payment_failed` and `invoice.voided` trigger the invoice reconcile, and other invoice events are skipped; payment-method events project rows from the stored payload | `internal/integrations/stripe/workflows/webhook.go` |
|
||||
| Invoice reconcile | `ReconcileInvoice`: reads the invoice, its lines and the charges of its payment intent back from the Stripe API at the SDK's version, takes a per-invoice lock, and converges the invoice (created and numbered by the first run that finds it issued, then only moved forward), its lines (inserted once), and one payment per settled charge | `internal/integrations/stripe/workflows/invoice_reconcile.go` |
|
||||
| Invoice sweep | The hourly Temporal schedule (`stripe-invoice-sweep`), also triggered at every boot, that reads back invoice mappings never read back, open invoices last read more than a day ago, and invoices named by dead-lettered invoice events, then fills payment methods and subscription links mapped since; it never renumbers, deletes, or reconciles a subscription. The Stripe provider page states when it last finished | `internal/integrations/stripe/workflows/invoice_sweep.go` |
|
||||
| Outbox drainer | The recurring worker that executes outbox rows (create Stripe product / price / customer), retrying with backoff until a cap parks the row as `dead_letter` for operator-driven retry | `internal/integrations/stripe/workflows/outbox.go` |
|
||||
| Outbox drainer | The recurring worker that executes outbox rows (create Stripe product / price / customer), retrying with backoff until a cap parks the row as `dead_letter` for operator-driven retry; a create Stripe answers with its own server error is parked at once, and an operator's retry looks for the object by the console's id in its metadata before it creates one under a new idempotency key | `internal/integrations/stripe/workflows/outbox.go` |
|
||||
| Reconciler | `ReconcileSubscription`: refetches the subscription from the Stripe API and converges console records and provisions to match. It runs on `customer.subscription.created`, `.updated` and `.deleted`, on `checkout.session.completed` for a subscription, on the checkout return (for the customer's most recent subscription), in the scheduled-change sweeper when it reaches a period-end downgrade or fires a term-boundary switch or cancellation, after each member plan change, and from an invoice event's reconcile when the invoice's subscription or one of its line items has no mapping yet. The reconcile writes the collection pause Stripe reports onto the subscription row on every run. A desired product whose ladder another plan took from the subscription and holds is left alone and logged; when grants hold every ladder the subscription's conferring products are left alone on, the reconcile asks for its collection to be paused. An hourly sweep, a second activity of the scheduled-change sweep workflow, reads the recorded pause and the rule's inputs from the database and asks again, through the outbox, for each live subscription whose pause disagrees with the rule; it skips a subscription that has an entry in flight or a dead letter from the last day, so a dead-lettered request is made again after a day. A subscription that pays for two tiers of one ladder gets the higher-ranked one, the other logged as a warning and not conferred (a subscription that alternated between the two before the reconcile chose by rank moves to the higher-ranked tier at its next reconcile and stays there); the reconcile writes in product id order under the pool row | `internal/fulfillment/reconcile.go` |
|
||||
| Plan-change mechanics | Member-initiated switch and cancel, a preview of the price difference before switching, the commitment gating, and the scheduled-change bookkeeping | `internal/fulfillment/plan_change.go` |
|
||||
| Scheduled-change sweeper | The hourly backstop that fires due scheduled changes Stripe itself cannot fire | `internal/fulfillment/sweep.go`; `internal/workflows/billing/` |
|
||||
|
||||
+1
-1
@@ -200,7 +200,7 @@ also reads every connected account, so weigh it there before adding it.
|
||||
|
||||
The member console is the catalog source of truth; Stripe holds a mirror used for payment execution. Catalog objects do **not** sync to Stripe automatically — syncing a product to Stripe is an **explicit operator action** (see "Making a product purchasable" below). Once synced, members subscribe via Stripe Checkout, Stripe runs the billing cycle, and webhooks project Stripe state (subscriptions, invoices, payments) back into the member console's core tables.
|
||||
|
||||
Sync runs through a transactional outbox: the operator action enqueues `create_stripe_product` and `create_stripe_price` entries, and a worker drains them against the Stripe API. The resulting `stripe.product_mappings` / `stripe.price_mappings` rows record the mapping and its `sync_status`, which moves from `pending` to `synced` (or to `deleted`, or to `stale` when the environment check cannot find the id under the current key). `dead_letter` is a status on the outbox entry itself, not on the mapping: when an entry fails repeatedly and the outbox marks it `dead_letter`, the mapping it was trying to create stays at `pending` until an operator retries the entry and the retry succeeds.
|
||||
Sync runs through a transactional outbox: the operator action enqueues `create_stripe_product` and `create_stripe_price` entries, and a worker drains them against the Stripe API. The resulting `stripe.product_mappings` / `stripe.price_mappings` rows record the mapping and its `sync_status`, which moves from `pending` to `synced` (or to `deleted`, or to `stale` when the environment check cannot find the id under the current key). `dead_letter` is a status on the outbox entry itself, not on the mapping: when an entry fails repeatedly and the outbox marks it `dead_letter`, the mapping it was trying to create stays at `pending` until an operator retries the entry and the retry succeeds. A create that Stripe answers with its own error object and a status of 500 or above is marked `dead_letter` after that one attempt, because Stripe answers every later request with the same idempotency key from the saved error, and a server error does not say whether Stripe made the object; the entry's reason says so. A gateway's error page and a request that gets no answer are retried as before. Retry sends each dead-lettered entry back with a new idempotency key. Before its create, a retried entry lists the active products, or the active prices of its product, that Stripe created since an hour before the entry was written, and maps the oldest whose metadata carries the console's `product_id` or `price_id`, a price also with the entry's amount, currency and interval; it creates the object only when none does. The list uses Stripe's list API, which shows what Stripe has written, and not its search, which lags; it needs the Products and Prices read permissions the environment check already uses.
|
||||
|
||||
Every Stripe object the console reads carries a `livemode` field naming the environment it exists in, except `SubscriptionItem`, which has no such field: a subscription item's environment can only be taken from its parent subscription.
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
-- +goose Up
|
||||
-- core.outbox.requeues counts the times an operator's Retry sent an entry
|
||||
-- from dead_letter back to pending (catalogsync.Sync). The Stripe outbox
|
||||
-- appends it to the entry's idempotency key, so a requeued create is not
|
||||
-- answered with what Stripe saved under the key an earlier send spent, a
|
||||
-- server error included, and looks for what that send made before it
|
||||
-- creates (#312). Zero is every existing row's count: a Retry before this
|
||||
-- column did not change the key, so every entry keeps the key it sends.
|
||||
ALTER TABLE core.outbox ADD COLUMN requeues INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE core.outbox DROP COLUMN requeues;
|
||||
@@ -85,9 +85,8 @@
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
hx-post="{{ routeURL "/partials/operator/products/{productID}/sync-stripe" .Product.ProductID }}"
|
||||
hx-target="#operator-body" hx-swap="innerHTML">
|
||||
Retry Stripe sync
|
||||
Find or create in Stripe
|
||||
</button>
|
||||
<small class="text-muted d-block mt-1">The last sync to Stripe failed; retry to run it again.</small>
|
||||
</div>
|
||||
{{ else if not .Readiness.StripeConfigured }}
|
||||
<div class="alert alert-secondary py-2 small mt-3 mb-0" role="alert">
|
||||
|
||||
@@ -27,7 +27,7 @@ const (
|
||||
// Nothing was written.
|
||||
AlreadySynced Outcome = iota + 1
|
||||
// Retrying: a dead-lettered create of the product or the price was sent
|
||||
// back to pending.
|
||||
// back to pending, its requeue counted.
|
||||
Retrying
|
||||
// PriceInProgress: the price's sync is pending. Nothing was written.
|
||||
PriceInProgress
|
||||
@@ -113,7 +113,11 @@ func FailedSync(ctx context.Context, d stripedb.DBTX, productID, priceID string)
|
||||
// per run, so an already-synced or in-flight sync is never enqueued again.
|
||||
// A terminally failed (dead-lettered) sync leaves the mapping stuck at
|
||||
// 'pending', so it is treated as a retry: the dead-lettered entries are
|
||||
// re-driven rather than the stuck mapping answered as in progress.
|
||||
// re-driven rather than the stuck mapping answered as in progress. Each
|
||||
// re-driven entry counts the requeue, so its create looks in Stripe for what
|
||||
// an earlier send made and is sent under a new idempotency key: the key an
|
||||
// earlier send spent answers with that send's saved answer, a server error
|
||||
// included.
|
||||
//
|
||||
// "Already synced" is a claim about an id this deployment can reach, so it
|
||||
// holds only while the mapping's recorded environment agrees with the key
|
||||
@@ -142,7 +146,7 @@ func Sync(ctx context.Context, d DB, productID string, product billing.Product,
|
||||
if failed, _ := FailedSync(ctx, d, productID, price.PriceID); failed {
|
||||
if _, err := d.ExecContext(ctx,
|
||||
`UPDATE core.outbox
|
||||
SET status = 'pending', attempts = 0, next_attempt_at = NOW(),
|
||||
SET status = 'pending', attempts = 0, requeues = requeues + 1, next_attempt_at = NOW(),
|
||||
error_message = NULL, updated_at = NOW()
|
||||
WHERE provider = 'stripe' AND status = 'dead_letter'
|
||||
AND ( (action_type = 'create_stripe_product' AND payload->>'product_id' = $1)
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
||||
@@ -40,8 +43,14 @@ type OutboxEntry struct {
|
||||
MaxAttempts int `db:"max_attempts"`
|
||||
// CreatedAt is when the row was written. ExecuteOutboxEntry reads it
|
||||
// from the row before acting, whatever the workflow input carried,
|
||||
// because the idempotency key is derived from it.
|
||||
// because the idempotency key is derived from it and from Requeues.
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
// Requeues is how many times an operator's Retry sent the entry back
|
||||
// (catalogsync.Sync). The idempotency key carries it, and a requeued
|
||||
// entry looks for what an earlier send made before it creates
|
||||
// (findProduct, findPrice). ExecuteOutboxEntry reads it from the row,
|
||||
// as it reads CreatedAt.
|
||||
Requeues int `db:"requeues"`
|
||||
}
|
||||
|
||||
// PollIntegrationOutboxInput configures the outbox polling workflow.
|
||||
@@ -59,10 +68,11 @@ func OutboxEntryWorkflowID(entryID int64) string {
|
||||
// The retry budget for one outbox entry, the same as a webhook event's:
|
||||
// Temporal retries the activity from one second, doubling to ten minutes,
|
||||
// for a day; a Stripe answer that says the request itself is wrong
|
||||
// (classifyStripeError) ends it at once; the spent budget dead-letters the
|
||||
// entry for the Stripe provider page. Every Stripe create carries the
|
||||
// entry's idempotency key, so a retry after a lost result reuses the object
|
||||
// the first attempt made rather than making another.
|
||||
// (classifyStripeError), or Stripe's own server error to a create sent under
|
||||
// the entry's key (stopOnSavedServerError), ends it at once; the spent budget
|
||||
// dead-letters the entry for the Stripe provider page. Every Stripe create
|
||||
// carries the entry's idempotency key, so a retry after a lost result reuses
|
||||
// the object the first attempt made rather than making another.
|
||||
const (
|
||||
outboxRetryBudget = 24 * time.Hour
|
||||
outboxRetryMaxInterval = 10 * time.Minute
|
||||
@@ -233,14 +243,15 @@ func (a *OutboxActivities) PollPendingOutboxEntries(ctx context.Context, batchSi
|
||||
// marks it `completed`. A failed attempt is recorded on the row (`failed`,
|
||||
// Temporal's attempt number, the error) before the error goes back to
|
||||
// Temporal to schedule the next one; an answer from Stripe that the request
|
||||
// itself is wrong is returned non-retryable. The row is re-read first: an
|
||||
// itself is wrong is returned non-retryable, and so is Stripe's own server
|
||||
// error to a create (stopOnSavedServerError). The row is re-read first: an
|
||||
// entry already completed by an earlier attempt whose result was lost is
|
||||
// not executed twice.
|
||||
func (a *OutboxActivities) ExecuteOutboxEntry(ctx context.Context, entry OutboxEntry) error {
|
||||
var status string
|
||||
if err := a.DB.QueryRowContext(ctx,
|
||||
`SELECT status, payload, action_type, created_at FROM core.outbox WHERE id = $1`, entry.ID,
|
||||
).Scan(&status, &entry.Payload, &entry.ActionType, &entry.CreatedAt); err != nil {
|
||||
`SELECT status, payload, action_type, created_at, requeues FROM core.outbox WHERE id = $1`, entry.ID,
|
||||
).Scan(&status, &entry.Payload, &entry.ActionType, &entry.CreatedAt, &entry.Requeues); err != nil {
|
||||
return fmt.Errorf("read outbox entry %d: %w", entry.ID, err)
|
||||
}
|
||||
if status == "completed" {
|
||||
@@ -296,7 +307,11 @@ func (a *OutboxActivities) MarkOutboxDeadLetter(ctx context.Context, entryID int
|
||||
// classifyStripeError turns a Stripe answer that the request itself is
|
||||
// wrong (a 4xx other than a rate limit or a conflict) into a non-retryable
|
||||
// error, so Temporal stops at once instead of asking the same question for
|
||||
// a day. Anything else, a network fault, a 5xx, a 429, stays retryable.
|
||||
// a day. Anything else, a network fault, a 5xx, a 429, stays retryable. A
|
||||
// create's own server error is stopped before this, by stopOnSavedServerError,
|
||||
// because Stripe saved it under the create's key; a server error here is a
|
||||
// reconcile's call or a lookup's list, each of whose next attempt is a new
|
||||
// request.
|
||||
func classifyStripeError(err error) error {
|
||||
var stripeErr *stripego.Error
|
||||
if !errors.As(err, &stripeErr) {
|
||||
@@ -309,6 +324,31 @@ func classifyStripeError(err error) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// outcomeUnknownErrorType is the Temporal error type of a create Stripe
|
||||
// answered with its own server error (stopOnSavedServerError).
|
||||
const outcomeUnknownErrorType = "StripeOutcomeUnknown"
|
||||
|
||||
// stopOnSavedServerError ends the retries of a create sent under the entry's
|
||||
// idempotency key when Stripe answered it with its own error object and a
|
||||
// status of 500 or above. Stripe saved that answer under the key once it
|
||||
// began executing the request and answers every later send with the key from
|
||||
// it, so no retry under the key can succeed; and a server error does not say
|
||||
// whether Stripe made the object. An error that is not Stripe's error object
|
||||
// (a deadline, a network fault, a gateway's error page, which stripe-go
|
||||
// returns as a plain error) reached no saved answer and is returned as it is,
|
||||
// as is every Stripe answer below 500, which classifyStripeError decides.
|
||||
// object names what the create makes, for the reason the entry is
|
||||
// dead-lettered with.
|
||||
func stopOnSavedServerError(object string, err error) error {
|
||||
var se *stripego.Error
|
||||
if !errors.As(err, &se) || se.HTTPStatusCode < 500 {
|
||||
return err
|
||||
}
|
||||
return temporal.NewNonRetryableApplicationError(
|
||||
fmt.Sprintf("Stripe answered with a server error; whether it created the %s is unknown", object),
|
||||
outcomeUnknownErrorType, err)
|
||||
}
|
||||
|
||||
// outboxIdempotencyKey is the Stripe idempotency key for an entry's create:
|
||||
// the same key on every attempt, so a retry after a lost result returns the
|
||||
// object the first attempt made (Stripe keeps keys for 24 hours, the retry
|
||||
@@ -316,9 +356,112 @@ func classifyStripeError(err error) error {
|
||||
// sequence after a database reset, a restore, or in a second database on
|
||||
// the same Stripe account, and Stripe refuses a key it has seen with other
|
||||
// parameters. The row's creation time, to the microsecond Postgres keeps,
|
||||
// tells those entries apart and never changes between attempts.
|
||||
// tells those entries apart and never changes between attempts. A requeue
|
||||
// (an operator's Retry) appends its count: Stripe answers every send under
|
||||
// the key an earlier send spent with that send's saved answer, a server
|
||||
// error included, so the requeued entry sends a key no earlier send used,
|
||||
// once it has looked for what an earlier send made (findProduct,
|
||||
// findPrice). An entry never requeued keeps the key it always had.
|
||||
func outboxIdempotencyKey(entry OutboxEntry) string {
|
||||
return fmt.Sprintf("member-console-outbox-%d-%d", entry.ID, entry.CreatedAt.UnixMicro())
|
||||
key := fmt.Sprintf("member-console-outbox-%d-%d", entry.ID, entry.CreatedAt.UnixMicro())
|
||||
if entry.Requeues > 0 {
|
||||
key += fmt.Sprintf("-%d", entry.Requeues)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// outboxLookupSkew is how far before the row's creation time a requeued
|
||||
// entry looks for an object an earlier send made. Every send came after the
|
||||
// row was written; the hour allows for a database clock ahead of Stripe's.
|
||||
const outboxLookupSkew = time.Hour
|
||||
|
||||
// lookupSince is the earliest creation time, in Stripe's Unix seconds, of an
|
||||
// object a send of entry can have made.
|
||||
func lookupSince(entry OutboxEntry) int64 {
|
||||
return entry.CreatedAt.Add(-outboxLookupSkew).Unix()
|
||||
}
|
||||
|
||||
// oldest is the first of found that Stripe created, by its creation time and
|
||||
// then its id, or nil when found is empty. Each other is logged: an object
|
||||
// an earlier send made that the console leaves unmapped, for an operator to
|
||||
// archive in the Dashboard.
|
||||
func oldest[T any](logger *slog.Logger, entry OutboxEntry, found []*T, idAt func(*T) (string, int64)) *T {
|
||||
if len(found) == 0 {
|
||||
return nil
|
||||
}
|
||||
slices.SortFunc(found, func(x, y *T) int {
|
||||
xi, xa := idAt(x)
|
||||
yi, ya := idAt(y)
|
||||
return cmp.Or(cmp.Compare(xa, ya), strings.Compare(xi, yi))
|
||||
})
|
||||
for _, other := range found[1:] {
|
||||
id, _ := idAt(other)
|
||||
logger.Warn("stripe outbox: an earlier send made another object for this entry; it stays unmapped",
|
||||
slog.Int64("entry_id", entry.ID), slog.String("stripe_id", id))
|
||||
}
|
||||
return found[0]
|
||||
}
|
||||
|
||||
// findProduct is the Stripe product an earlier send of a requeued entry
|
||||
// made, or nil: an active product created since lookupSince whose metadata
|
||||
// names the entry's product. It reads Stripe's list, which shows what Stripe
|
||||
// has written; Stripe's search lags its writes and is not offered to
|
||||
// accounts in India. The list carries the attempt's context.
|
||||
func (a *OutboxActivities) findProduct(ctx context.Context, entry OutboxEntry, productID string) (*stripego.Product, error) {
|
||||
params := &stripego.ProductListParams{
|
||||
Active: stripego.Bool(true),
|
||||
CreatedRange: &stripego.RangeQueryParams{GreaterThanOrEqual: lookupSince(entry)},
|
||||
}
|
||||
params.Context = ctx
|
||||
var found []*stripego.Product
|
||||
it := product.List(params)
|
||||
for it.Next() {
|
||||
if p := it.Product(); p.Metadata["product_id"] == productID {
|
||||
found = append(found, p)
|
||||
}
|
||||
}
|
||||
if err := it.Err(); err != nil {
|
||||
return nil, fmt.Errorf("stripe list products: %w", err)
|
||||
}
|
||||
return oldest(a.Logger, entry, found, func(p *stripego.Product) (string, int64) { return p.ID, p.Created }), nil
|
||||
}
|
||||
|
||||
// findPrice is the Stripe price an earlier send of a requeued entry made, or
|
||||
// nil: an active price of the mapped Stripe product created since
|
||||
// lookupSince that samePrice finds to be the entry's. Like findProduct, it
|
||||
// reads Stripe's list under the attempt's context.
|
||||
func (a *OutboxActivities) findPrice(ctx context.Context, entry OutboxEntry, stripeProductID string, payload CreateStripePricePayload) (*stripego.Price, error) {
|
||||
params := &stripego.PriceListParams{
|
||||
Product: stripego.String(stripeProductID),
|
||||
Active: stripego.Bool(true),
|
||||
CreatedRange: &stripego.RangeQueryParams{GreaterThanOrEqual: lookupSince(entry)},
|
||||
}
|
||||
params.Context = ctx
|
||||
var found []*stripego.Price
|
||||
it := price.List(params)
|
||||
for it.Next() {
|
||||
if p := it.Price(); samePrice(p, payload) {
|
||||
found = append(found, p)
|
||||
}
|
||||
}
|
||||
if err := it.Err(); err != nil {
|
||||
return nil, fmt.Errorf("stripe list prices: %w", err)
|
||||
}
|
||||
return oldest(a.Logger, entry, found, func(p *stripego.Price) (string, int64) { return p.ID, p.Created }), nil
|
||||
}
|
||||
|
||||
// samePrice reports whether p is the price payload's create makes: the
|
||||
// console's price id in its metadata, and the amount, currency and interval
|
||||
// that decide what a member is charged.
|
||||
func samePrice(p *stripego.Price, payload CreateStripePricePayload) bool {
|
||||
interval := ""
|
||||
if p.Recurring != nil {
|
||||
interval = string(p.Recurring.Interval)
|
||||
}
|
||||
return p.Metadata["price_id"] == payload.PriceID &&
|
||||
p.UnitAmount == payload.UnitAmount &&
|
||||
strings.EqualFold(string(p.Currency), payload.Currency) &&
|
||||
interval == payload.RecurringInterval
|
||||
}
|
||||
|
||||
// executeAction dispatches to the appropriate action executor based on action type.
|
||||
@@ -377,11 +520,12 @@ func (a *OutboxActivities) executeCreateStripeCustomer(ctx context.Context, entr
|
||||
params.AddMetadata("org_id", payload.OrgID)
|
||||
params.AddMetadata("org_name", payload.OrgName)
|
||||
params.AddMetadata("billing_account_id", payload.BillingAccountID)
|
||||
params.Context = ctx
|
||||
params.SetIdempotencyKey(outboxIdempotencyKey(entry))
|
||||
|
||||
cust, err := customer.New(params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stripe create customer: %w", err)
|
||||
return stopOnSavedServerError("customer", fmt.Errorf("stripe create customer: %w", err))
|
||||
}
|
||||
|
||||
// Write the mapping row.
|
||||
@@ -435,22 +579,37 @@ func buildStripeProductParams(payload CreateStripeProductPayload) *stripego.Prod
|
||||
return params
|
||||
}
|
||||
|
||||
// executeCreateStripeProduct creates a Stripe Product and writes a product_mappings row.
|
||||
// executeCreateStripeProduct maps the entry's product to a Stripe product and
|
||||
// writes a product_mappings row. A requeued entry first looks for the
|
||||
// product an earlier send made (findProduct) and maps it without a create;
|
||||
// otherwise, and when it finds none, the product is created under the
|
||||
// entry's idempotency key and the attempt's context. A create Stripe answers
|
||||
// with its own server error stops the entry (stopOnSavedServerError).
|
||||
func (a *OutboxActivities) executeCreateStripeProduct(ctx context.Context, entry OutboxEntry) error {
|
||||
var payload CreateStripeProductPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return fmt.Errorf("parse create_stripe_product payload: %w", err)
|
||||
}
|
||||
|
||||
params := buildStripeProductParams(payload)
|
||||
params.SetIdempotencyKey(outboxIdempotencyKey(entry))
|
||||
prod, err := product.New(params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stripe create product: %w", err)
|
||||
var prod *stripego.Product
|
||||
var err error
|
||||
if entry.Requeues > 0 {
|
||||
if prod, err = a.findProduct(ctx, entry, payload.ProductID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
found := prod != nil
|
||||
if !found {
|
||||
params := buildStripeProductParams(payload)
|
||||
params.Context = ctx
|
||||
params.SetIdempotencyKey(outboxIdempotencyKey(entry))
|
||||
if prod, err = product.New(params); err != nil {
|
||||
return stopOnSavedServerError("product", fmt.Errorf("stripe create product: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
q := internalstripe.New(a.DB)
|
||||
// Livemode from the product Stripe returned (stripe-environment-stamp D1).
|
||||
// Livemode from the product Stripe returned or listed (stripe-environment-stamp D1).
|
||||
_, err = q.UpsertProductMapping(ctx, internalstripe.UpsertProductMappingParams{
|
||||
ProductID: payload.ProductID,
|
||||
StripeProductID: sql.NullString{String: prod.ID, Valid: true},
|
||||
@@ -461,9 +620,10 @@ func (a *OutboxActivities) executeCreateStripeProduct(ctx context.Context, entry
|
||||
return fmt.Errorf("write product mapping: %w", err)
|
||||
}
|
||||
|
||||
a.Logger.Info("stripe product created",
|
||||
a.Logger.Info("stripe product mapped",
|
||||
slog.String("stripe_product_id", prod.ID),
|
||||
slog.String("product_id", payload.ProductID))
|
||||
slog.String("product_id", payload.ProductID),
|
||||
slog.Bool("found_by_lookup", found))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -479,6 +639,9 @@ type CreateStripePricePayload struct {
|
||||
|
||||
// executeCreateStripePrice looks up the Stripe Product ID, creates a Stripe Price,
|
||||
// and writes a price_mappings row. Returns a retriable error if the product is not yet synced.
|
||||
// A requeued entry first looks for the price an earlier send made
|
||||
// (findPrice) and maps it without a create; a create Stripe answers with its
|
||||
// own server error stops the entry (stopOnSavedServerError).
|
||||
func (a *OutboxActivities) executeCreateStripePrice(ctx context.Context, entry OutboxEntry) error {
|
||||
var payload CreateStripePricePayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
@@ -495,26 +658,34 @@ func (a *OutboxActivities) executeCreateStripePrice(ctx context.Context, entry O
|
||||
return fmt.Errorf("product %s not yet synced (retriable): sync_status=%s", payload.ProductID, prodMapping.SyncStatus)
|
||||
}
|
||||
|
||||
params := &stripego.PriceParams{
|
||||
Product: stripego.String(prodMapping.StripeProductID.String),
|
||||
UnitAmount: stripego.Int64(payload.UnitAmount),
|
||||
Currency: stripego.String(payload.Currency),
|
||||
}
|
||||
if payload.RecurringInterval != "" {
|
||||
params.Recurring = &stripego.PriceRecurringParams{
|
||||
Interval: stripego.String(payload.RecurringInterval),
|
||||
var p *stripego.Price
|
||||
if entry.Requeues > 0 {
|
||||
if p, err = a.findPrice(ctx, entry, prodMapping.StripeProductID.String, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
params.AddMetadata("price_id", payload.PriceID)
|
||||
params.AddMetadata("product_id", payload.ProductID)
|
||||
params.SetIdempotencyKey(outboxIdempotencyKey(entry))
|
||||
|
||||
p, err := price.New(params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stripe create price: %w", err)
|
||||
found := p != nil
|
||||
if !found {
|
||||
params := &stripego.PriceParams{
|
||||
Product: stripego.String(prodMapping.StripeProductID.String),
|
||||
UnitAmount: stripego.Int64(payload.UnitAmount),
|
||||
Currency: stripego.String(payload.Currency),
|
||||
}
|
||||
if payload.RecurringInterval != "" {
|
||||
params.Recurring = &stripego.PriceRecurringParams{
|
||||
Interval: stripego.String(payload.RecurringInterval),
|
||||
}
|
||||
}
|
||||
params.AddMetadata("price_id", payload.PriceID)
|
||||
params.AddMetadata("product_id", payload.ProductID)
|
||||
params.Context = ctx
|
||||
params.SetIdempotencyKey(outboxIdempotencyKey(entry))
|
||||
if p, err = price.New(params); err != nil {
|
||||
return stopOnSavedServerError("price", fmt.Errorf("stripe create price: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Livemode from the price Stripe returned (stripe-environment-stamp D1).
|
||||
// Livemode from the price Stripe returned or listed (stripe-environment-stamp D1).
|
||||
_, err = q.UpsertPriceMapping(ctx, internalstripe.UpsertPriceMappingParams{
|
||||
PriceID: payload.PriceID,
|
||||
StripePriceID: sql.NullString{String: p.ID, Valid: true},
|
||||
@@ -525,9 +696,10 @@ func (a *OutboxActivities) executeCreateStripePrice(ctx context.Context, entry O
|
||||
return fmt.Errorf("write price mapping: %w", err)
|
||||
}
|
||||
|
||||
a.Logger.Info("stripe price created",
|
||||
a.Logger.Info("stripe price mapped",
|
||||
slog.String("stripe_price_id", p.ID),
|
||||
slog.String("price_id", payload.PriceID))
|
||||
slog.String("price_id", payload.PriceID),
|
||||
slog.Bool("found_by_lookup", found))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -30,3 +30,34 @@ func TestOutboxIdempotencyKeyOutlivesTheSequence(t *testing.T) {
|
||||
t.Errorf("key = %q", outboxIdempotencyKey(morning))
|
||||
}
|
||||
}
|
||||
|
||||
// A requeue (an operator's Retry) gives the entry a key no earlier send
|
||||
// used, each requeue its own; an attempt within a run never changes the key,
|
||||
// and an entry never requeued keeps the key it always had.
|
||||
func TestARequeuedEntrySendsANewKey(t *testing.T) {
|
||||
entry := OutboxEntry{ID: 1, CreatedAt: time.Date(2026, 9, 27, 9, 0, 0, 123456000, time.UTC)}
|
||||
|
||||
const never = "member-console-outbox-1-1790499600123456"
|
||||
if got := outboxIdempotencyKey(entry); got != never {
|
||||
t.Errorf("a never-requeued entry's key is %q, want %q", got, never)
|
||||
}
|
||||
|
||||
once := entry
|
||||
once.Requeues = 1
|
||||
const first = never + "-1"
|
||||
if got := outboxIdempotencyKey(once); got != first {
|
||||
t.Errorf("the first requeue's key is %q, want %q", got, first)
|
||||
}
|
||||
|
||||
twice := entry
|
||||
twice.Requeues = 2
|
||||
if got := outboxIdempotencyKey(twice); got == first {
|
||||
t.Errorf("two requeues share the key %q", got)
|
||||
}
|
||||
|
||||
later := once
|
||||
later.Attempts = 3
|
||||
if got := outboxIdempotencyKey(later); got != first {
|
||||
t.Errorf("an attempt of a requeued run changed its key: %q, then %q", first, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package workflows_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
stripego "github.com/stripe/stripe-go/v81"
|
||||
"go.temporal.io/sdk/temporal"
|
||||
|
||||
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
|
||||
stripewf "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/workflows"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/stripetest"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
|
||||
)
|
||||
|
||||
// stripeServerError is Stripe's own server error, the answer a create gets
|
||||
// that Stripe saves under its idempotency key.
|
||||
func stripeServerError() error {
|
||||
return &stripego.Error{Type: stripego.ErrorTypeAPI, HTTPStatusCode: 500, Msg: "server error"}
|
||||
}
|
||||
|
||||
// catalogStripe answers the product, price and customer endpoints as Stripe
|
||||
// would, for the mock's Handle. A create makes an active object stamped now
|
||||
// from the form, or answers Stripe's own 500 and makes nothing while
|
||||
// serverError is set; a list answers the held objects that pass the form's
|
||||
// active, created[gte] and, for prices, product, newest first. A parameter the
|
||||
// form does not carry filters nothing. A customer create always answers 500.
|
||||
type catalogStripe struct {
|
||||
serverError bool
|
||||
listErr error
|
||||
products []*stripego.Product
|
||||
prices []*stripego.Price
|
||||
made []string // the ids of the objects creates made, in order
|
||||
counter int
|
||||
}
|
||||
|
||||
// handle is the mock's Handle: it answers the endpoints above and lets every
|
||||
// other call fall through.
|
||||
func (cs *catalogStripe) handle(c stripetest.Call) (any, error, bool) {
|
||||
switch c.Method + " " + c.Path {
|
||||
case "POST /v1/customers":
|
||||
return nil, stripeServerError(), true
|
||||
case "POST /v1/products":
|
||||
return cs.createProduct(c)
|
||||
case "POST /v1/prices":
|
||||
return cs.createPrice(c)
|
||||
case "GET /v1/products":
|
||||
if cs.listErr != nil {
|
||||
return nil, cs.listErr, true
|
||||
}
|
||||
since, err := listSince(c)
|
||||
if err != nil {
|
||||
return nil, invalidRequest(err), true
|
||||
}
|
||||
list := &stripego.ProductList{}
|
||||
for _, p := range cs.products {
|
||||
if passes(c, p.Active, p.Created, since) {
|
||||
list.Data = append(list.Data, p)
|
||||
}
|
||||
}
|
||||
slices.SortStableFunc(list.Data, func(x, y *stripego.Product) int { return int(y.Created - x.Created) })
|
||||
return list, nil, true
|
||||
case "GET /v1/prices":
|
||||
if cs.listErr != nil {
|
||||
return nil, cs.listErr, true
|
||||
}
|
||||
since, err := listSince(c)
|
||||
if err != nil {
|
||||
return nil, invalidRequest(err), true
|
||||
}
|
||||
list := &stripego.PriceList{}
|
||||
for _, p := range cs.prices {
|
||||
if passes(c, p.Active, p.Created, since) && (c.Form.Get("product") == "" || c.Form.Get("product") == p.Product.ID) {
|
||||
list.Data = append(list.Data, p)
|
||||
}
|
||||
}
|
||||
slices.SortStableFunc(list.Data, func(x, y *stripego.Price) int { return int(y.Created - x.Created) })
|
||||
return list, nil, true
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
// invalidRequest is Stripe's refusal of a malformed request, so a test fails
|
||||
// on a form the code under test got wrong.
|
||||
func invalidRequest(err error) error {
|
||||
return &stripego.Error{Type: stripego.ErrorTypeInvalidRequest, HTTPStatusCode: 400, Msg: err.Error()}
|
||||
}
|
||||
|
||||
// listSince is the list form's created[gte], 0 when the form carries none, or
|
||||
// an error when it is not a number.
|
||||
func listSince(c stripetest.Call) (int64, error) {
|
||||
gte := c.Form.Get("created[gte]")
|
||||
if gte == "" {
|
||||
return 0, nil
|
||||
}
|
||||
return strconv.ParseInt(gte, 10, 64)
|
||||
}
|
||||
|
||||
// passes reports whether an object with this active flag and creation time
|
||||
// passes the list form's active and created[gte], which is since.
|
||||
func passes(c stripetest.Call, active bool, created, since int64) bool {
|
||||
if c.Form.Get("active") == "true" && !active {
|
||||
return false
|
||||
}
|
||||
return created >= since
|
||||
}
|
||||
|
||||
// createProduct answers POST /v1/products: Stripe's own 500 while serverError
|
||||
// is set, otherwise an active product made from the form and stamped now.
|
||||
func (cs *catalogStripe) createProduct(c stripetest.Call) (any, error, bool) {
|
||||
if cs.serverError {
|
||||
return nil, stripeServerError(), true
|
||||
}
|
||||
cs.counter++
|
||||
id := fmt.Sprintf("prod_%s_%d", c.Form.Get("metadata[product_id]"), cs.counter)
|
||||
p := &stripego.Product{
|
||||
ID: id, Active: true, Created: time.Now().Unix(),
|
||||
Metadata: map[string]string{"product_id": c.Form.Get("metadata[product_id]")},
|
||||
}
|
||||
cs.products = append(cs.products, p)
|
||||
cs.made = append(cs.made, id)
|
||||
return p, nil, true
|
||||
}
|
||||
|
||||
// createPrice answers POST /v1/prices: Stripe's own 500 while serverError is
|
||||
// set, a refusal when unit_amount is not a number, otherwise an active price
|
||||
// made from the form and stamped now.
|
||||
func (cs *catalogStripe) createPrice(c stripetest.Call) (any, error, bool) {
|
||||
if cs.serverError {
|
||||
return nil, stripeServerError(), true
|
||||
}
|
||||
amount, err := strconv.ParseInt(c.Form.Get("unit_amount"), 10, 64)
|
||||
if err != nil {
|
||||
return nil, invalidRequest(err), true
|
||||
}
|
||||
cs.counter++
|
||||
id := fmt.Sprintf("price_%s_%d", c.Form.Get("metadata[price_id]"), cs.counter)
|
||||
p := &stripego.Price{
|
||||
ID: id, Active: true, Created: time.Now().Unix(),
|
||||
Product: &stripego.Product{ID: c.Form.Get("product")},
|
||||
UnitAmount: amount,
|
||||
Currency: stripego.Currency(c.Form.Get("currency")),
|
||||
Metadata: map[string]string{
|
||||
"price_id": c.Form.Get("metadata[price_id]"),
|
||||
"product_id": c.Form.Get("metadata[product_id]"),
|
||||
},
|
||||
}
|
||||
if interval := c.Form.Get("recurring[interval]"); interval != "" {
|
||||
p.Recurring = &stripego.PriceRecurring{Interval: stripego.PriceRecurringInterval(interval)}
|
||||
}
|
||||
cs.prices = append(cs.prices, p)
|
||||
cs.made = append(cs.made, id)
|
||||
return p, nil, true
|
||||
}
|
||||
|
||||
// callName is how a test names a call: "the list" for a product or price
|
||||
// list, "the create" for a product, price or customer create, otherwise the
|
||||
// call's line.
|
||||
func callName(c stripetest.Call) string {
|
||||
switch c.Method + " " + c.Path {
|
||||
case "GET /v1/products", "GET /v1/prices":
|
||||
return "the list"
|
||||
case "POST /v1/products", "POST /v1/prices", "POST /v1/customers":
|
||||
return "the create"
|
||||
}
|
||||
return stripetest.Line(c)
|
||||
}
|
||||
|
||||
// callNames is the names of the calls the mock received, in order.
|
||||
func callNames(m *stripetest.MockBackend) []string {
|
||||
var names []string
|
||||
for _, c := range m.Calls {
|
||||
names = append(names, callName(c))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// requeuedEntry inserts a stripe outbox row with the payload's JSON and the
|
||||
// requeue count, deletes it when the test ends, and returns its id and the
|
||||
// creation time Postgres gave it.
|
||||
func requeuedEntry(t *testing.T, db *sql.DB, action string, payload any, requeues int) (int64, time.Time) {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal the payload: %v", err)
|
||||
}
|
||||
var id int64
|
||||
var createdAt time.Time
|
||||
if err := db.QueryRowContext(context.Background(),
|
||||
`INSERT INTO core.outbox (provider, action_type, payload, requeues) VALUES ('stripe', $1, $2, $3) RETURNING id, created_at`,
|
||||
action, body, requeues).Scan(&id, &createdAt); err != nil {
|
||||
t.Fatalf("insert the outbox row: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = db.ExecContext(context.Background(), `DELETE FROM core.outbox WHERE id = $1`, id) })
|
||||
return id, createdAt
|
||||
}
|
||||
|
||||
// outboxState is the status, the attempts and the error of the outbox row id.
|
||||
func outboxState(t *testing.T, db *sql.DB, id int64) (status string, attempts int, errMsg string) {
|
||||
t.Helper()
|
||||
var msg sql.NullString
|
||||
if err := db.QueryRowContext(context.Background(),
|
||||
`SELECT status, attempts, error_message FROM core.outbox WHERE id = $1`, id).Scan(&status, &attempts, &msg); err != nil {
|
||||
t.Fatalf("read outbox row %d: %v", id, err)
|
||||
}
|
||||
return status, attempts, msg.String
|
||||
}
|
||||
|
||||
// mappedID is the Stripe id of the synced mapping of the console's product
|
||||
// (or, when price is set, price) id, or "" when it has none.
|
||||
func mappedID(t *testing.T, db *sql.DB, price bool, id string) string {
|
||||
t.Helper()
|
||||
q := stripedb.New(db)
|
||||
if price {
|
||||
m, err := q.GetPriceMappingByPriceID(context.Background(), id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ""
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read the mapping of %s: %v", id, err)
|
||||
}
|
||||
if m.SyncStatus != "synced" {
|
||||
return ""
|
||||
}
|
||||
return m.StripePriceID.String
|
||||
}
|
||||
m, err := q.GetProductMappingByProductID(context.Background(), id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ""
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read the mapping of %s: %v", id, err)
|
||||
}
|
||||
if m.SyncStatus != "synced" {
|
||||
return ""
|
||||
}
|
||||
return m.StripeProductID.String
|
||||
}
|
||||
|
||||
// quietActivities are the outbox activities on db with their log discarded.
|
||||
func quietActivities(db *sql.DB) *stripewf.OutboxActivities {
|
||||
return stripewf.NewOutboxActivities(db, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
}
|
||||
|
||||
// retryable reports whether err is neither nil nor Temporal's non-retryable
|
||||
// application error.
|
||||
func retryable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
appErr, ok := err.(*temporal.ApplicationError) //nolint:errorlint // Temporal reads only a top-level ApplicationError as non-retryable
|
||||
return !ok || !appErr.NonRetryable()
|
||||
}
|
||||
|
||||
// keyedEntry is one outbox entry of a test: the world's product and price,
|
||||
// the entry's row, and the mock Stripe answering it.
|
||||
type keyedEntry struct {
|
||||
w *world.World
|
||||
product world.Product
|
||||
price world.Price
|
||||
id int64
|
||||
createdAt time.Time
|
||||
stripe *catalogStripe
|
||||
mock *stripetest.MockBackend
|
||||
acts *stripewf.OutboxActivities
|
||||
}
|
||||
|
||||
// newKeyedEntry builds the fixture of one entry. action is create_stripe_product,
|
||||
// create_stripe_price or create_stripe_customer; a price entry's product has a
|
||||
// synced mapping to "prod_" followed by its id, and its price is recurring at
|
||||
// interval, or one-time when interval is "". The mock is installed until the
|
||||
// test ends.
|
||||
func newKeyedEntry(t *testing.T, db *sql.DB, action, currency, interval string, requeues int) *keyedEntry {
|
||||
t.Helper()
|
||||
e := &keyedEntry{w: world.New(t, db), stripe: &catalogStripe{}}
|
||||
e.product = e.w.Product("Outcome")
|
||||
if interval == "" {
|
||||
e.price = e.w.OneTimePrice(e.product, 1000)
|
||||
} else {
|
||||
e.price = e.w.Price(e.product, 1000)
|
||||
}
|
||||
var payload any
|
||||
switch action {
|
||||
case "create_stripe_product":
|
||||
payload = stripewf.CreateStripeProductPayload{ProductID: e.product.ID, Name: e.product.Name}
|
||||
case "create_stripe_price":
|
||||
payload = stripewf.CreateStripePricePayload{
|
||||
PriceID: e.price.ID, ProductID: e.product.ID, UnitAmount: 1000, Currency: currency, RecurringInterval: interval,
|
||||
}
|
||||
e.w.MapProductIn(e.product, false)
|
||||
default:
|
||||
payload = stripedb.EnsureStripeCustomerPayload{
|
||||
BillingAccountID: uuid.NewString(), OrgID: uuid.NewString(), OrgName: "Outcome Org", BillingAccountName: "Outcome Account",
|
||||
}
|
||||
}
|
||||
e.id, e.createdAt = requeuedEntry(t, db, action, payload, requeues)
|
||||
e.mock = &stripetest.MockBackend{Handle: e.stripe.handle}
|
||||
t.Cleanup(stripetest.Install(e.mock))
|
||||
e.acts = quietActivities(db)
|
||||
return e
|
||||
}
|
||||
|
||||
// seedSpec is one object Stripe already holds when a requeued entry runs.
|
||||
type seedSpec struct {
|
||||
offset time.Duration // from the row's creation time
|
||||
inactive bool
|
||||
otherConsoleID bool // carries another console id
|
||||
otherProduct bool // a price of another Stripe product
|
||||
amount int64 // a price's amount; 0 is the entry's
|
||||
currency string // "" is usd
|
||||
interval string // "" is month
|
||||
oneTime bool // a price with no interval
|
||||
}
|
||||
|
||||
// seedProduct makes Stripe hold a product as s describes, with the id
|
||||
// "prod_seed<i>_" and the console product's id.
|
||||
func (e *keyedEntry) seedProduct(i int, s seedSpec) string {
|
||||
consoleID := e.product.ID
|
||||
if s.otherConsoleID {
|
||||
consoleID = uuid.NewString()
|
||||
}
|
||||
p := &stripego.Product{
|
||||
ID: fmt.Sprintf("prod_seed%d_%s", i, e.product.ID),
|
||||
Active: !s.inactive,
|
||||
Created: e.createdAt.Add(s.offset).Unix(),
|
||||
Metadata: map[string]string{"product_id": consoleID},
|
||||
}
|
||||
e.stripe.products = append(e.stripe.products, p)
|
||||
return p.ID
|
||||
}
|
||||
|
||||
// seedPrice makes Stripe hold a price as s describes: of the mapped Stripe
|
||||
// product, the entry's amount, usd and monthly, unless s says otherwise.
|
||||
func (e *keyedEntry) seedPrice(i int, s seedSpec) string {
|
||||
priceID := e.price.ID
|
||||
if s.otherConsoleID {
|
||||
priceID = uuid.NewString()
|
||||
}
|
||||
stripeProduct := "prod_" + e.product.ID
|
||||
if s.otherProduct {
|
||||
stripeProduct = "prod_" + uuid.NewString()
|
||||
}
|
||||
p := &stripego.Price{
|
||||
ID: fmt.Sprintf("price_seed%d_%s", i, e.price.ID),
|
||||
Active: !s.inactive,
|
||||
Created: e.createdAt.Add(s.offset).Unix(),
|
||||
Product: &stripego.Product{ID: stripeProduct},
|
||||
UnitAmount: cmp.Or(s.amount, 1000),
|
||||
Currency: stripego.Currency(cmp.Or(s.currency, "usd")),
|
||||
Metadata: map[string]string{"price_id": priceID, "product_id": e.product.ID},
|
||||
}
|
||||
if !s.oneTime {
|
||||
p.Recurring = &stripego.PriceRecurring{Interval: stripego.PriceRecurringInterval(cmp.Or(s.interval, "month"))}
|
||||
}
|
||||
e.stripe.prices = append(e.stripe.prices, p)
|
||||
return p.ID
|
||||
}
|
||||
|
||||
// keyOf is the idempotency key of the create the mock received, "" when it
|
||||
// received none.
|
||||
func (e *keyedEntry) keyOf() string {
|
||||
for _, c := range e.mock.Calls {
|
||||
if callName(c) == "the create" {
|
||||
return c.IdempotencyKey
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestAKeyedCreateAnsweredWithAServerErrorStops: a create Stripe answers with
|
||||
// its own 500 ends the entry's retries at its first attempt and leaves no
|
||||
// mapping, because Stripe saved the answer under the key.
|
||||
func TestAKeyedCreateAnsweredWithAServerErrorStops(t *testing.T) {
|
||||
database := dbtest.Open(t)
|
||||
for _, row := range []struct{ name, action, object string }{
|
||||
{"a product", "create_stripe_product", "product"},
|
||||
{"a price", "create_stripe_price", "price"},
|
||||
{"a customer", "create_stripe_customer", "customer"},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
e := newKeyedEntry(t, database, row.action, "usd", "month", 0)
|
||||
e.stripe.serverError = true
|
||||
|
||||
err := e.acts.ExecuteOutboxEntry(context.Background(), stripewf.OutboxEntry{ID: e.id})
|
||||
|
||||
appErr, ok := err.(*temporal.ApplicationError) //nolint:errorlint // Temporal reads only a top-level ApplicationError as non-retryable
|
||||
if !ok || !appErr.NonRetryable() || appErr.Type() != "StripeOutcomeUnknown" {
|
||||
t.Errorf("%s: the attempt returned %v, want a non-retryable StripeOutcomeUnknown", row.name, err)
|
||||
}
|
||||
if got := callNames(e.mock); !slices.Equal(got, []string{"the create"}) {
|
||||
t.Errorf("%s: the attempt sent %v, want the create alone", row.name, got)
|
||||
}
|
||||
status, attempts, msg := outboxState(t, database, e.id)
|
||||
if want := "whether it created the " + row.object + " is unknown"; status != "failed" || attempts != 1 || !strings.Contains(msg, want) {
|
||||
t.Errorf("%s: the row reads %s, %d, %q, want failed, 1 and the unknown outcome", row.name, status, attempts, msg)
|
||||
}
|
||||
if row.object == "customer" {
|
||||
return
|
||||
}
|
||||
id := e.product.ID
|
||||
if row.object == "price" {
|
||||
id = e.price.ID
|
||||
}
|
||||
if got := mappedID(t, database, row.object == "price", id); got != "" {
|
||||
t.Errorf("%s: the attempt left the mapping %q, want none", row.name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// created is a lookupRow's mapped value for an entry that creates the object.
|
||||
const created = -1
|
||||
|
||||
// lookupRow is one case of TestARequeuedCreateLooksBeforeItCreates: the
|
||||
// entry, what Stripe holds, and what the entry sends and maps.
|
||||
type lookupRow struct {
|
||||
name string
|
||||
price bool
|
||||
oneTime bool // a price with no interval
|
||||
firstSend bool // requeues 0; otherwise 1
|
||||
currency string
|
||||
seeds []seedSpec
|
||||
listErr bool // Stripe answers the list with its own 500
|
||||
calls []string
|
||||
wantStatus string // the row the entry leaves, "failed" or "completed"; "" is completed
|
||||
mapped int // the index of the seed the mapping holds, or created
|
||||
unmapped []int // the indexes of the seeds the log names as left unmapped
|
||||
|
||||
checkKey bool
|
||||
keySuffix string // the key's end after the creation time
|
||||
}
|
||||
|
||||
// The offsets, from a row's creation time, at which a test's seeded objects
|
||||
// are stamped.
|
||||
const (
|
||||
afterRow = time.Second
|
||||
beforeRow = -10 * time.Minute
|
||||
beforeWindow = -2 * time.Hour
|
||||
)
|
||||
|
||||
// checkLookup runs row's entry and checks the calls it sends, the mapping it
|
||||
// writes and the row it leaves.
|
||||
func checkLookup(t *testing.T, database *sql.DB, row lookupRow) {
|
||||
t.Helper()
|
||||
action, requeues := "create_stripe_product", 1
|
||||
if row.price {
|
||||
action = "create_stripe_price"
|
||||
}
|
||||
if row.firstSend {
|
||||
requeues = 0
|
||||
}
|
||||
interval := "month"
|
||||
if row.oneTime {
|
||||
interval = ""
|
||||
}
|
||||
e := newKeyedEntry(t, database, action, cmp.Or(row.currency, "usd"), interval, requeues)
|
||||
var logged bytes.Buffer
|
||||
e.acts = stripewf.NewOutboxActivities(database, slog.New(slog.NewTextHandler(&logged, nil)))
|
||||
seeded := make([]string, len(row.seeds))
|
||||
for i, s := range row.seeds {
|
||||
if row.price {
|
||||
seeded[i] = e.seedPrice(i, s)
|
||||
} else {
|
||||
seeded[i] = e.seedProduct(i, s)
|
||||
}
|
||||
}
|
||||
if row.listErr {
|
||||
e.stripe.listErr = stripeServerError()
|
||||
}
|
||||
|
||||
err := e.acts.ExecuteOutboxEntry(context.Background(), stripewf.OutboxEntry{ID: e.id})
|
||||
|
||||
if got := callNames(e.mock); !slices.Equal(got, row.calls) {
|
||||
t.Errorf("%s: the entry sent %v, want %v", row.name, got, row.calls)
|
||||
}
|
||||
consoleID := e.product.ID
|
||||
if row.price {
|
||||
consoleID = e.price.ID
|
||||
}
|
||||
got := mappedID(t, database, row.price, consoleID)
|
||||
status, _, _ := outboxState(t, database, e.id)
|
||||
wantStatus := cmp.Or(row.wantStatus, "completed")
|
||||
if status != wantStatus {
|
||||
t.Errorf("%s: the entry reads %q, want %s", row.name, status, wantStatus)
|
||||
}
|
||||
if wantStatus == "failed" {
|
||||
if !retryable(err) {
|
||||
t.Errorf("%s: the entry returned %v, want a retryable error", row.name, err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("%s: the mapping holds %q, want none", row.name, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("%s: the entry returned %v, want nil", row.name, err)
|
||||
}
|
||||
want := ""
|
||||
if row.mapped == created {
|
||||
if len(e.stripe.made) > 0 {
|
||||
want = e.stripe.made[0]
|
||||
}
|
||||
} else {
|
||||
want = seeded[row.mapped]
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("%s: the mapping holds %q, want %q", row.name, got, want)
|
||||
}
|
||||
for _, i := range row.unmapped {
|
||||
if !strings.Contains(logged.String(), seeded[i]) {
|
||||
t.Errorf("%s: the log names %q, want the other object %q", row.name, logged.String(), seeded[i])
|
||||
}
|
||||
}
|
||||
if row.checkKey {
|
||||
wantKey := fmt.Sprintf("member-console-outbox-%d-%d%s", e.id, e.createdAt.UnixMicro(), row.keySuffix)
|
||||
if key := e.keyOf(); key != wantKey {
|
||||
t.Errorf("%s: the create carried key %q, want %q", row.name, key, wantKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
lookupList = []string{"the list"}
|
||||
lookupThenCreate = []string{"the list", "the create"}
|
||||
createOnly = []string{"the create"}
|
||||
oneSeed = []seedSpec{{offset: afterRow}}
|
||||
otherAmountSeed = []seedSpec{{offset: afterRow, amount: 2500}}
|
||||
otherCurrencySeed = []seedSpec{{offset: afterRow, currency: "eur"}}
|
||||
otherIntervalSeed = []seedSpec{{offset: afterRow, interval: "year"}}
|
||||
archivedSeed = []seedSpec{{offset: afterRow, inactive: true}}
|
||||
otherIDSeed = []seedSpec{{offset: afterRow, otherConsoleID: true}}
|
||||
)
|
||||
|
||||
// A requeued entry looks in Stripe for the object an earlier send made, and
|
||||
// maps the oldest it finds before it creates one; an entry never requeued
|
||||
// does not look.
|
||||
func TestARequeuedCreateLooksBeforeItCreates(t *testing.T) {
|
||||
database := dbtest.Open(t)
|
||||
for _, row := range []lookupRow{
|
||||
{name: "a product Stripe made", seeds: oneSeed, calls: lookupList, mapped: 0},
|
||||
{name: "a product Stripe stamped before the row", seeds: []seedSpec{{offset: beforeRow}}, calls: lookupList, mapped: 0},
|
||||
{name: "a product made before the window", seeds: []seedSpec{{offset: beforeWindow}}, calls: lookupThenCreate, mapped: created},
|
||||
{name: "an archived product", seeds: archivedSeed, calls: lookupThenCreate, mapped: created},
|
||||
{name: "another product in the window", seeds: otherIDSeed, calls: lookupThenCreate, mapped: created},
|
||||
{name: "two products Stripe made", seeds: []seedSpec{{offset: 5 * afterRow}, {offset: afterRow}}, calls: lookupList, mapped: 1, unmapped: []int{0}},
|
||||
{name: "two products Stripe made in one second", seeds: []seedSpec{{offset: afterRow}, {offset: afterRow}}, calls: lookupList, mapped: 0},
|
||||
{name: "nothing made", calls: lookupThenCreate, mapped: created, checkKey: true, keySuffix: "-1"},
|
||||
{name: "a first send", firstSend: true, seeds: oneSeed, calls: createOnly, mapped: created, checkKey: true},
|
||||
{name: "a first price send", price: true, firstSend: true, seeds: oneSeed, calls: createOnly, mapped: created, checkKey: true},
|
||||
{name: "a price Stripe made", price: true, seeds: oneSeed, calls: lookupList, mapped: 0},
|
||||
{name: "a one-time price Stripe made", price: true, oneTime: true, seeds: []seedSpec{{offset: afterRow, oneTime: true}}, calls: lookupList, mapped: 0},
|
||||
{name: "a price the console stores in capitals", price: true, currency: "USD", seeds: oneSeed, calls: lookupList, mapped: 0},
|
||||
{name: "a price of another product", price: true, seeds: []seedSpec{{offset: afterRow, otherProduct: true}}, calls: lookupThenCreate, mapped: created},
|
||||
{name: "a price with another amount", price: true, seeds: otherAmountSeed, calls: lookupThenCreate, mapped: created},
|
||||
{name: "a price in another currency", price: true, seeds: otherCurrencySeed, calls: lookupThenCreate, mapped: created},
|
||||
{name: "a price with another interval", price: true, seeds: otherIntervalSeed, calls: lookupThenCreate, mapped: created},
|
||||
{name: "an archived price", price: true, seeds: archivedSeed, calls: lookupThenCreate, mapped: created},
|
||||
{name: "another price in the window", price: true, seeds: otherIDSeed, calls: lookupThenCreate, mapped: created},
|
||||
{name: "the list fails", listErr: true, calls: lookupList, wantStatus: "failed"},
|
||||
{name: "the price list fails", price: true, listErr: true, calls: lookupList, wantStatus: "failed"},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) { checkLookup(t, database, row) })
|
||||
}
|
||||
}
|
||||
|
||||
// Each Stripe call of an entry, the list and the create alike, carries the
|
||||
// attempt's context, so the attempt's deadline bounds it.
|
||||
func TestOutboxCreatesCarryTheAttemptsContext(t *testing.T) {
|
||||
database := dbtest.Open(t)
|
||||
for _, row := range []struct {
|
||||
name, action string
|
||||
requeues int
|
||||
calls []string
|
||||
failing bool
|
||||
}{
|
||||
{"a product", "create_stripe_product", 1, lookupThenCreate, false},
|
||||
{"a price", "create_stripe_price", 1, lookupThenCreate, false},
|
||||
{"a customer", "create_stripe_customer", 0, createOnly, true},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
e := newKeyedEntry(t, database, row.action, "usd", "month", row.requeues)
|
||||
e.stripe.serverError = row.failing
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
|
||||
defer cancel()
|
||||
want, _ := ctx.Deadline()
|
||||
|
||||
// The row checks only the deadlines of the calls, not the attempt's result.
|
||||
_ = e.acts.ExecuteOutboxEntry(ctx, stripewf.OutboxEntry{ID: e.id})
|
||||
|
||||
if got := callNames(e.mock); !slices.Equal(got, row.calls) {
|
||||
t.Fatalf("%s: the entry sent %v, want %v", row.name, got, row.calls)
|
||||
}
|
||||
for _, c := range e.mock.Calls {
|
||||
if !c.Deadline.Equal(want) {
|
||||
t.Errorf("%s: %s carried deadline %v, want %v", row.name, callName(c), c.Deadline, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,10 @@ package workflows
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
@@ -21,16 +24,18 @@ func entryUnderTest() OutboxEntry {
|
||||
}
|
||||
|
||||
type outboxDeadLetterRecorder struct {
|
||||
calls int
|
||||
lastID int64
|
||||
calls int
|
||||
lastID int64
|
||||
lastMsg string
|
||||
}
|
||||
|
||||
func mockOutboxDeadLetter(env *testsuite.TestWorkflowEnvironment, rec *outboxDeadLetterRecorder) {
|
||||
var acts *OutboxActivities
|
||||
env.OnActivity(acts.MarkOutboxDeadLetter, mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(func(_ context.Context, id int64, _ string) error {
|
||||
Return(func(_ context.Context, id int64, msg string) error {
|
||||
rec.calls++
|
||||
rec.lastID = id
|
||||
rec.lastMsg = msg
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -159,3 +164,82 @@ func TestClassifyStripeError(t *testing.T) {
|
||||
t.Error("a plain error must stay retryable")
|
||||
}
|
||||
}
|
||||
|
||||
// stopRow is one case of TestStopOnSavedServerError: the error a create
|
||||
// returned and whether the stop ends the entry's retries.
|
||||
type stopRow struct {
|
||||
name string
|
||||
err error
|
||||
stops bool
|
||||
}
|
||||
|
||||
// checkStop passes row's error, wrapped as the product create wraps it, to
|
||||
// stopOnSavedServerError and checks that it stops the entry, as a
|
||||
// non-retryable StripeOutcomeUnknown, exactly when the row says it does, and
|
||||
// otherwise comes back as it went in.
|
||||
func checkStop(t *testing.T, row stopRow) {
|
||||
t.Helper()
|
||||
wrapped := fmt.Errorf("stripe create product: %w", row.err)
|
||||
got := stopOnSavedServerError("product", wrapped)
|
||||
if !row.stops {
|
||||
if got != wrapped { //nolint:errorlint // the very error that went in, not one that wraps it
|
||||
t.Errorf("%s: the create's error came back %v, want it unchanged", row.name, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
const reason = "Stripe answered with a server error; whether it created the product is unknown"
|
||||
var appErr *temporal.ApplicationError
|
||||
if !errors.As(got, &appErr) || !appErr.NonRetryable() || appErr.Type() != "StripeOutcomeUnknown" || appErr.Message() != reason {
|
||||
t.Errorf("%s: the create's error came back %v, want a non-retryable StripeOutcomeUnknown reading %q", row.name, got, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// Stripe saves its own server error under the create's key and answers every
|
||||
// later send with the key from it, so the create's retries stop; every other
|
||||
// error is returned as it is.
|
||||
func TestStopOnSavedServerError(t *testing.T) {
|
||||
for _, row := range []stopRow{
|
||||
{name: "Stripe's own 500", err: &stripego.Error{Type: stripego.ErrorTypeAPI, HTTPStatusCode: 500, Msg: "server error"}, stops: true},
|
||||
{name: "Stripe's own 503", err: &stripego.Error{Type: stripego.ErrorTypeAPI, HTTPStatusCode: 503, Msg: "server error"}, stops: true},
|
||||
{name: "a gateway's error page", err: errors.New("Couldn't deserialize JSON (response status: 502, body sample: '<html><head><title>502 Bad Gateway</title></head></html>'): invalid character '<' looking for beginning of value")},
|
||||
{name: "no answer", err: &url.Error{Op: "Post", URL: "https://api.stripe.com/v1/products", Err: context.DeadlineExceeded}},
|
||||
{name: "a refusal", err: &stripego.Error{Type: stripego.ErrorTypeInvalidRequest, HTTPStatusCode: 400, Msg: "refused"}},
|
||||
{name: "a conflict", err: &stripego.Error{Type: stripego.ErrorTypeInvalidRequest, HTTPStatusCode: 409, Msg: "refused"}},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) { checkStop(t, row) })
|
||||
}
|
||||
}
|
||||
|
||||
// An entry whose create Stripe answered with its own server error is
|
||||
// attempted once and dead-lettered with the reason that says the outcome is
|
||||
// unknown.
|
||||
func TestOutboxEntryWorkflowDeadLettersAnUnknownOutcome(t *testing.T) {
|
||||
var suite testsuite.WorkflowTestSuite
|
||||
env := suite.NewTestWorkflowEnvironment()
|
||||
var acts *OutboxActivities
|
||||
rec := &outboxDeadLetterRecorder{}
|
||||
mockOutboxDeadLetter(env, rec)
|
||||
|
||||
attempts := 0
|
||||
env.OnActivity(acts.ExecuteOutboxEntry, mock.Anything, mock.Anything).
|
||||
Return(func(_ context.Context, _ OutboxEntry) error {
|
||||
attempts++
|
||||
return stopOnSavedServerError("price", fmt.Errorf("stripe create price: %w",
|
||||
&stripego.Error{Type: stripego.ErrorTypeAPI, HTTPStatusCode: 500, Msg: "server error"}))
|
||||
})
|
||||
|
||||
env.ExecuteWorkflow(ExecuteStripeOutboxEntry, entryUnderTest())
|
||||
|
||||
if err := env.GetWorkflowError(); err == nil {
|
||||
t.Fatal("an unknown outcome must fail the workflow")
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Errorf("an unknown outcome was attempted %d times, want once", attempts)
|
||||
}
|
||||
if rec.calls != 1 || rec.lastID != 7 {
|
||||
t.Errorf("the entry was dead-lettered %d times, as %d; want once, as 7", rec.calls, rec.lastID)
|
||||
}
|
||||
if !strings.Contains(rec.lastMsg, "whether it created the price is unknown") {
|
||||
t.Errorf("the dead letter reads %q, want the unknown outcome", rec.lastMsg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,6 +273,7 @@ read core.outbox.max_attempts
|
||||
read core.outbox.next_attempt_at
|
||||
withhold core.outbox.payload
|
||||
read core.outbox.provider
|
||||
read core.outbox.requeues
|
||||
read core.outbox.status
|
||||
read core.outbox.updated_at
|
||||
read core.payment_methods.billing_account_id
|
||||
|
||||
@@ -227,7 +227,7 @@ func TestProductEditRendersPurchasabilityPanel(t *testing.T) {
|
||||
true, "stripe: invalid api key", nil)
|
||||
_, doc := renderPanel(t, vm)
|
||||
wantCountOfText(t, doc, "span.badge", "Sync failed", 1)
|
||||
wantTextOf(t, doc, "#product-readiness button", "Retry Stripe sync")
|
||||
wantTextOf(t, doc, "#product-readiness button", "Find or create in Stripe")
|
||||
if got := rowDetailText(t, doc, "Payment processing"); !strings.Contains(got, "stripe: invalid api key") {
|
||||
t.Errorf("Payment processing detail = %q, want the failure error", got)
|
||||
}
|
||||
|
||||
@@ -420,3 +420,59 @@ func TestSyncProductToStripeRechecksUnderTheLock(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// entryRequeues is how many times one outbox entry was sent back.
|
||||
type entryRequeues struct {
|
||||
action string
|
||||
count int
|
||||
}
|
||||
|
||||
// requeues is the requeue count of every Stripe catalog-sync entry of the
|
||||
// product and its prices, in id order.
|
||||
func (e *syncEnv) requeues(productID string) []entryRequeues {
|
||||
e.t.Helper()
|
||||
var out []entryRequeues
|
||||
for _, row := range dbtest.On(e.t, e.w.DB).Strings(`SELECT action_type || ' ' || requeues `+outboxOf+` ORDER BY id`, productID) {
|
||||
action, count, _ := strings.Cut(row, " ")
|
||||
n, err := strconv.Atoi(count)
|
||||
if err != nil {
|
||||
e.t.Fatalf("read requeues %q: %v", row, err)
|
||||
}
|
||||
out = append(out, entryRequeues{action, n})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// checkRequeues reports each entry of the product whose requeue count is not
|
||||
// want, naming the moment the count was read.
|
||||
func (e *syncEnv) checkRequeues(productID, moment string, want int) {
|
||||
e.t.Helper()
|
||||
entries := e.requeues(productID)
|
||||
if len(entries) != 2 {
|
||||
e.t.Fatalf("%s, the product has %d entries, want its product and its price", moment, len(entries))
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.count != want {
|
||||
e.t.Errorf("%s, %s has requeues %d, want %d", moment, entry.action, entry.count, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Retry counts each time it sends a dead-lettered entry back, once per
|
||||
// dead-letter however many times the button is pressed, so the entry's next
|
||||
// create is sent under a key no earlier send used.
|
||||
func TestRetryCountsTheRequeue(t *testing.T) {
|
||||
e := newSyncEnv(t)
|
||||
p := e.product("Plan")
|
||||
e.press(p.ID, "")
|
||||
e.deadLetter(p.ID, "create_stripe_product", "create_stripe_price")
|
||||
e.press(p.ID, "")
|
||||
e.checkRequeues(p.ID, "after the first Retry", 1)
|
||||
|
||||
e.press(p.ID, "")
|
||||
e.checkRequeues(p.ID, "after a press while the sync runs", 1)
|
||||
|
||||
e.deadLetter(p.ID, "create_stripe_product", "create_stripe_price")
|
||||
e.press(p.ID, "")
|
||||
e.checkRequeues(p.ID, "after the second Retry", 2)
|
||||
}
|
||||
|
||||
@@ -506,7 +506,7 @@ func commerceRows(in readinessInputs, syncFailed bool, syncError string) (rows [
|
||||
if syncError != "" {
|
||||
stripeRow.Detail += ": " + syncError
|
||||
}
|
||||
stripeRow.Detail += ". Use Retry to run it again."
|
||||
stripeRow.Detail += "."
|
||||
case pr.SyncPending:
|
||||
stripeRow.State = statePending
|
||||
stripeRow.Detail = "The price is syncing to Stripe; this clears once the sync lands."
|
||||
|
||||
Vendored
+1
-2
@@ -1,7 +1,6 @@
|
||||
status 286
|
||||
small.text-muted: Add a rule to the entitlement set; a set with none confers nothing.
|
||||
small.text-muted: The last Stripe sync failed: fixture refusal. Use Retry to run it again.
|
||||
small.text-muted: The last sync to Stripe failed; retry to run it again.
|
||||
small.text-muted: The last Stripe sync failed: fixture refusal.
|
||||
|
||||
outbox
|
||||
action_type | payload | status | attempts | error
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-30
|
||||
@@ -0,0 +1,229 @@
|
||||
## Context
|
||||
|
||||
**The outbox.** `catalogsync.Sync` (`internal/integrations/stripe/catalogsync/catalogsync.go:137-159`), the Sync to Stripe action on a product's page, writes pending mappings and enqueues a `create_stripe_product` and a `create_stripe_price` row in `core.outbox`. The relay hands each row to its own workflow, `ExecuteStripeOutboxEntry` (`internal/integrations/stripe/workflows/outbox.go:119-133`), which runs `ExecuteOutboxEntry` under `budgetedOptions` (`retry_budget.go:15-29`): two minutes an attempt, retried from one second, doubling to ten minutes, within a day. `ExecuteOutboxEntry` (`outbox.go:232-269`) re-reads the row, marks it `processing`, dispatches on `action_type`, passes the executor's error through `classifyStripeError` (`:296-310`), records a failed attempt on the row and returns the error to Temporal. `classifyStripeError` makes a 400, 401, 402, 403, 404 or 422 non-retryable and leaves everything else retryable, a 500 included. When the run fails for good, `runThenDeadLetter` (`retry_budget.go:31-50`) dead-letters the row through `MarkOutboxDeadLetter` with the run's error text.
|
||||
|
||||
**The key.** Every create carries `outboxIdempotencyKey(entry)`, `member-console-outbox-<id>-<creation time in microseconds>` (`outbox.go:312-322`), the same on every attempt; #147 added the creation time so that an id the sequence reuses after a reset or a restore does not reuse a key. Retry (`catalogsync.go:142-154`) finds the product's dead-lettered product and price rows (`FailedSync`, `:84-105`) and sets them back to `pending` with `attempts = 0`; the id and the creation time stay, so the key stays.
|
||||
|
||||
**What Stripe does with a key.** Stripe saves the status code and body of the first request with a key once the endpoint has begun executing it, and answers every later request with that key from the saved answer, "including `500` errors" (Stripe API reference, Idempotent requests); it keeps a key for at least 24 hours (`docs/stripe.md`, "How it works"). For a 500 Stripe's guidance is: "Treat the outcome of a request that returns `500` as indeterminate ... Don't retry with a new idempotency key until you've resolved the original outcome. Retrieve the relevant object ..."; during an incident's recovery Stripe may still make the object and send its event, and "When creating a resource, include a local identifier in its metadata" (Stripe, Advanced error handling, Server errors). `docs/stripe.md` (`:209`) already states the console's reading: a write is worth sending again under its key only when Stripe gave no answer (a deadline, a network fault, or a gateway's error page, which stripe-go returns as a plain error and not as a `*stripe.Error`). `gotNoAnswer` (`internal/fulfillment/plan_change.go:668`) is that rule in the plan change's code.
|
||||
|
||||
**What stripe-go v81.4.0 does before the outbox sees an error.** `NewRequest` adds the `Idempotency-Key` header once (`stripe.go:563-577`), and `requestWithRetriesAndTelemetry` (`:686-731`) sends that same request again on each of its retries, at most two (`DefaultMaxNetworkRetries`, `:54`), so every retry carries the entry's key. A response of 400 or above goes through `ResponseToError` (`:838-890`). A body that is not JSON, such as a gateway's HTML page, fails `UnmarshalJSONVerbose` (`:907`) and comes back as a plain `Couldn't deserialize JSON (response status: 502, body sample: '...')` error; JSON without an `error` object comes back as a plain error carrying the body (`:855-858`); only Stripe's own error object becomes a `*stripe.Error` with `HTTPStatusCode` set. `shouldRetry` (`:946-1031`) retries every plain error except a redirect loop, a bad scheme or a certificate failure, without reading any header (`:972-993`); for a `*stripe.Error` it obeys `Stripe-Should-Retry` when present (`:996-1001`) and otherwise retries a 409 and any status of 500 or above (`:1004`, `:1022-1029`). When Stripe sends no `Stripe-Should-Retry` header, stripe-go sends the request twice more, about one and a half seconds, and gets the saved 500; with `false`, which stripe-go expects on most 500s, it sends it once. Either way the outbox then retried it for a day. A gateway's page is retried twice by stripe-go and then by the outbox, which is right: Stripe saved nothing for it.
|
||||
|
||||
**What the operator sees today.** While the entry retries, the row is `failed`, which `FailedSync` treats as in flight, and the readiness panel's Payment processing row reads "The price is syncing to Stripe; this clears once the sync lands." (`internal/server/product_readiness.go:510-513`). Once it is dead-lettered, the row reads "The last Stripe sync failed: <stored reason>. Use Retry to run it again." (`:503-509`), the Retry Stripe sync control replaces Sync to Stripe with a second line under it, "The last sync to Stripe failed; retry to run it again." (`internal/embeds/templates/partials/operator_product_readiness.html:90`), and the Stripe integration page's Delivery queue lists the row with its reason in the Error column (`internal/server/operator_integrations_stripe.go:315-345`).
|
||||
|
||||
**What the console writes into the objects.** `buildStripeProductParams` puts `product_id` into the product's metadata (`outbox.go:431`); the price create puts `price_id` and `product_id` (`:508-509`); the customer create puts `org_id`, `org_name` and `billing_account_id` (`:377-379`). The product and price webhook handlers look a mapping up by the object's Stripe id only and drop an event for an id no mapping holds (`internal/integrations/stripe/workflows/webhook.go:491-495`, `:601-605`).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- A create Stripe answered with its own server error stops at its first attempt with a reason that says the outcome is unknown.
|
||||
- Retry never answers itself with the saved error of an earlier send, and never makes a second object when Stripe made the first.
|
||||
- Every other answer keeps its outcome.
|
||||
|
||||
**Non-Goals:**
|
||||
- Mapping from Stripe's events (open question 1), and the stored reason's raw chain, which #149 covers.
|
||||
- A Retry for customers, which nothing enqueues.
|
||||
- The plan change's own writes, which already follow `gotNoAnswer`.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. Which answers stop a create
|
||||
|
||||
Pick: a create sent under the entry's key whose error holds Stripe's own error object (`*stripe.Error`) with a status of 500 or above is returned non-retryable, with the Temporal error type `StripeOutcomeUnknown` and the reason "Stripe answered with a server error; whether it created the <object> is unknown", `<object>` being `product`, `price` or `customer`. Every other error goes on as today: a plain error (a gateway's page, a deadline, a network fault) stays retryable, and `classifyStripeError` still decides the 4xx.
|
||||
|
||||
```go
|
||||
// stopOnSavedServerError ends the retries of a create sent under the entry's
|
||||
// idempotency key when Stripe answered it with its own error object and a
|
||||
// status of 500 or above. Stripe saved that answer under the key once it
|
||||
// began executing the request and answers every later send with the key from
|
||||
// it, so no retry under the key can succeed; and a server error does not say
|
||||
// whether Stripe made the object. An error that is not Stripe's error object
|
||||
// (a deadline, a network fault, a gateway's error page, which stripe-go
|
||||
// returns as a plain error) reached no saved answer and is returned as it is,
|
||||
// as is every Stripe answer below 500, which classifyStripeError decides.
|
||||
func stopOnSavedServerError(object string, err error) error {
|
||||
var se *stripego.Error
|
||||
if !errors.As(err, &se) || se.HTTPStatusCode < 500 {
|
||||
return err
|
||||
}
|
||||
return temporal.NewNonRetryableApplicationError(
|
||||
fmt.Sprintf("Stripe answered with a server error; whether it created the %s is unknown", object),
|
||||
outcomeUnknownErrorType, err)
|
||||
}
|
||||
```
|
||||
|
||||
It runs at the three create calls, on the create's own error, and nowhere else. `ExecuteOutboxEntry`'s `classifyStripeError` then finds the 500 inside the application error and returns it unchanged, so the error reaches Temporal non-retryable, and `runThenDeadLetter` dead-letters the entry after one attempt. The `reconcile_subscription` action passes through `classifyStripeError` too (`outbox.go:361`, `:257`), but it sends no key of the entry's own: stripe-go generates a key per call, so a later attempt is a new request that Stripe executes afresh, and the reconcile fetches the subscription again before it decides (`reconcile-calls-carry-context`, D4). A 500 on its call stays retryable, which `TestClassifyStripeError` already pins.
|
||||
|
||||
The line is the one `docs/stripe.md` and `gotNoAnswer` draw: Stripe's error object means Stripe answered, and an answer saved under the key will be the answer to every send with the key. A status of 500 or above is treated alike whatever its number, as the plan change treats it.
|
||||
|
||||
Rejected: honouring `Stripe-Should-Retry: true` on a 5xx and keeping it retryable. stripe-go already obeys the header with its own two retries before the outbox sees the error, and a second reading in the outbox would state the saved-answer rule differently from `gotNoAnswer`, which `CONTRIBUTING.md` sends to an issue of its own. A server error Stripe marked safe that still fails after stripe-go's retries costs the operator one Retry, which looks first (D2) and then succeeds.
|
||||
|
||||
Rejected: stopping in `classifyStripeError`. It serves `reconcile_subscription` as well, whose 500 must stay retryable.
|
||||
|
||||
### D2. Finding what an earlier send made
|
||||
|
||||
Pick: an entry with `requeues` above zero (D3) looks before every create attempt; an entry never requeued does not look. The lookup reads Stripe's list API, keeps the objects whose metadata carries the console's id, maps the oldest and marks the entry done; when none matches, the entry creates the object under its key.
|
||||
|
||||
- **Products:** `product.List` with `active=true` and `created[gte]` one hour before the row's creation time; a product matches when `metadata.product_id` is the entry's product id.
|
||||
- **Prices:** `price.List` with `product=<the product mapping's Stripe id>`, `active=true` and the same `created[gte]`; a price matches when `metadata.price_id` is the entry's price id and its `unit_amount`, `currency` and `recurring.interval` are the entry's, because those decide what a member is charged.
|
||||
- **Several matches:** the oldest by Stripe's `created`, then by id, is mapped; every other is logged at warn level with the entry id and its Stripe id, `stripe outbox: an earlier send made another object for this entry; it stays unmapped`, for an operator to archive in the Dashboard.
|
||||
- **The window:** every send of an entry comes after its row was written, so an object it made has `created` at or after the row's creation time by Stripe's clock. The hour (`outboxLookupSkew`) allows for a database clock ahead of Stripe's (`created_at` is the database's `NOW()`); it also keeps out the objects an earlier database on the same account made for the same ids (#147's demo reset and restore) more than an hour before the row.
|
||||
- **The context:** each list carries the attempt's context, so the attempt's deadline bounds it.
|
||||
- **A failed list** returns its error: a 4xx is decided by `classifyStripeError`, a 5xx or a plain error is retried. A list is a read and carries no key, so a retry asks afresh. Nothing is created after a failed list.
|
||||
|
||||
Why the list and not search: Stripe's search lags its writes ("Don't use search for read-after-write flows ... use the various list APIs ... These APIs aren't subject to the availability delays", Stripe, Search, Data freshness), and "Search isn't available to businesses in India", which a general-purpose deployment can be. The list cannot filter by metadata, so the window and the `product` filter keep it short and the metadata is compared in the console.
|
||||
|
||||
Why at Retry and not at the stop: Stripe answered 500, usually during an incident, and may still make the object while it recovers. A lookup at the stop that found nothing could not create anyway (the key replays the 500), and creating under a new key at once is what Stripe's guidance says not to do. The operator's Retry is the moment the console resolves the outcome and only then spends a new key. A lookup at the stop that did find the object would spare one press in the rarest case and add a second path to test.
|
||||
|
||||
Why every attempt of a requeued run looks, not only its first: the condition is one fact on the row, and an attempt whose create got no answer is followed by an attempt that may find the object it made; the key would return it too, so the look costs one list; when an earlier send's object also exists, the oldest is mapped and the key's object is logged.
|
||||
|
||||
Found: the mapping is written from the listed object, `livemode` included, exactly as from a created one (`UpsertProductMapping`, `UpsertPriceMapping`, `sync_status = 'synced'`), and the entry is completed. The log line after the mapping reads `stripe product mapped` / `stripe price mapped` with `found_by_lookup`; no test reads the old `stripe product created` and `stripe price created`.
|
||||
|
||||
Rejected: Stripe's search API, for the reasons above. Rejected: a `lookup_key` on prices, which Stripe can list exactly: products have none, every existing price lacks one, and Stripe refuses a second price with the same key, which would turn a duplicate into a new refusal to handle.
|
||||
|
||||
### D3. The key after a requeue
|
||||
|
||||
Pick: `core.outbox.requeues INTEGER NOT NULL DEFAULT 0`, the number of times an operator's Retry sent the entry back. Retry's update sets `requeues = requeues + 1` beside `status = 'pending'` and `attempts = 0`. `ExecuteOutboxEntry` reads it with the row, and the key appends it when it is above zero:
|
||||
|
||||
```go
|
||||
func outboxIdempotencyKey(entry OutboxEntry) string {
|
||||
key := fmt.Sprintf("member-console-outbox-%d-%d", entry.ID, entry.CreatedAt.UnixMicro())
|
||||
if entry.Requeues > 0 {
|
||||
key += fmt.Sprintf("-%d", entry.Requeues)
|
||||
}
|
||||
return key
|
||||
}
|
||||
```
|
||||
|
||||
Every attempt of one run sends one key, because only Retry changes the count and Retry matches only `dead_letter` rows, which no workflow is running. Two presses of Retry count once: the second update finds no `dead_letter` row. An entry never requeued, every row in flight at the deploy included, keeps the key it sends today, so a lost answer before the deploy is still returned by Stripe after it. The default guesses nothing: a Retry before this change did not change the key, so zero is every existing row's true count of key-changing requeues.
|
||||
|
||||
Each Retry spends a new key, whatever the dead-letter's reason. After a 500 it must, or the saved 500 answers again. After a spent budget the old key may have expired, and after a refusal the key holds the refusal, so a new key is right there too, and the lookup before it makes it safe.
|
||||
|
||||
Rejected: reusing the key on Retry. The saved 500 answers every send for at least a day, which is #312.
|
||||
|
||||
Rejected: Retry enqueuing a fresh entry and retiring the old row. The fresh row's id and creation time give a new key without a column, but the retired row needs a status of its own that every status reader (`FailedSync`, the Delivery queue list, `CountOutboxByStatus`, the poll, the boot sweep) must learn, and the entry's history splits across two rows.
|
||||
|
||||
Rejected: deriving the suffix from `next_attempt_at`, which Retry also sets. The due time is not an identity, and any later writer of it would change the key under a running entry.
|
||||
|
||||
Rejected: a persisted random key per entry, as Stripe's guidance suggests for retries across processes. It replaces #147's derivation and needs the unfinished rows' current keys written into the column by the migration so that their lost answers stay reachable.
|
||||
|
||||
### D4. The outbox kinds
|
||||
|
||||
| Kind | Sends the entry's key | Producer | The stop (D1) | The lookup and the requeue key (D2, D3) |
|
||||
|---|---|---|---|---|
|
||||
| `create_stripe_product` | yes (`outbox.go:446`) | `catalogsync.Sync` | yes | yes |
|
||||
| `create_stripe_price` | yes (`:510`) | `catalogsync.Sync` | yes | yes |
|
||||
| `create_stripe_customer` | yes (`:380`) | none: checkout creates customers in the request (`internal/integrations/stripe/store/ensure_customer.go`) | yes, so a row left from before stops instead of retrying a day | no: Retry matches product and price rows only, so its requeue count stays zero; its metadata carries `billing_account_id` should a Retry ever exist |
|
||||
| `reconcile_subscription` | no: stripe-go's own key per call (`:349-363`) | `entitlements.RequestSubscriptionReconcile` | no: a 500 stays retryable (D1) | no |
|
||||
| any other | no call | none | no | no |
|
||||
|
||||
### D5. What the operator sees
|
||||
|
||||
While a create is retried after a gateway's page or a lost answer, nothing changes: the row is `failed` and the panel reads "The price is syncing to Stripe; this clears once the sync lands." After Stripe's own server error, the entry is dead-lettered after one attempt, within seconds: the panel's Payment processing row shows Sync failed with the stored reason, the Find or create in Stripe control (C4) replaces Sync to Stripe, and the Delivery queue lists the entry with the reason in its Error column and one attempt. The stored reason is the run's error text as Temporal formats it, `activity error (type: ExecuteOutboxEntry, scheduledEventID: …, startedEventID: …, identity: …): Stripe answered with a server error; whether it created the price is unknown (type: StripeOutcomeUnknown, retryable: false): stripe create price: {"status":500,"message":"server error","type":"api_error"} (type: wrapError, retryable: true): {"status":500,"message":"server error","type":"api_error"} (type: Error, retryable: true)`, as every dead-letter's reason is today (a real Stripe error body also carries `request_id` and `request_log_url`); the raw chain is #149. After Retry, the row reads "syncing" again until the lookup or the create lands, then Synced; the toast is today's "Retrying sync to Stripe; the mapping will appear here shortly." When a product's create stops, its price entry keeps failing "product … not yet synced (retriable)" on Temporal's schedule for a day, an ERROR line per attempt (#304), and dead-letters at the budget's end; after a Retry that requeues the product alone, the price lands at its next backoff, up to ten minutes later (`outboxRetryMaxInterval`), while the panel reads "syncing".
|
||||
|
||||
The row's closing sentence, which explains the control, goes, and so does the second line under the Retry control, which repeats the row above it (`AGENTS.md`, UI copy; `ui-vocabulary`, "Copy earns its place"). The row ends at the stored reason and a full stop; the control names what Retry does. If C4 is not approved, the control keeps `Retry Stripe sync`, and the page does not say that Retry looks in Stripe before it creates.
|
||||
|
||||
#### Copy table
|
||||
|
||||
| # | Where | Old | New | Status |
|
||||
|---|---|---|---|---|
|
||||
| C1 | The reason a create Stripe answered with its own server error is dead-lettered with: on the readiness panel's Payment processing row after "The last Stripe sync failed: ", and in the Error column of the Stripe integration page's Delivery queue | None: the entry retried for a day under "The price is syncing to Stripe; this clears once the sync lands.", and its reason was then the spent budget's | `Stripe answered with a server error; whether it created the price is unknown` (`product`, `customer` for those entries) | approved |
|
||||
| C2 | Payment processing row, after a failed sync's reason | `Use Retry to run it again.` | None (removed): the row ends at the stored reason and a full stop, for every dead-letter; the row holds one stored string (`syncError`) and the code cannot tell the unknown-outcome reason apart | approved |
|
||||
| C3 | Under the Retry Stripe sync control | `The last sync to Stripe failed; retry to run it again.` | (removed) | approved |
|
||||
| C4 | The Retry control's label | `Retry Stripe sync` | `Find or create in Stripe` | approved |
|
||||
|
||||
With C4, the toast after the press stays `Retrying sync to Stripe; the mapping will appear here shortly.` (`internal/server/operator_billing.go:1528`), and the console's other retry controls stay `Retry`.
|
||||
|
||||
### D6. Ride-alongs
|
||||
|
||||
Listed under `CONTRIBUTING.md`, "How changes are made", item 1, apart from the first:
|
||||
|
||||
- **The three creates carry the attempt's context** (`params.Context = ctx`), which #312's correctness needs, and is not a ride-along. Retry matches only `dead_letter` rows, "which no workflow is running" (D3); today, when the day's budget ends mid-attempt, the create keeps running in the worker for up to about four minutes (stripe-go's 80-second timeout and two retries, no context), so an operator's immediate Retry can list nothing and create under the new key while the earlier create is still landing. With the context the create ends with its attempt. The same missing context also makes Temporal's next attempt send the same key beside a running create, which Stripe answers 409. The rule is #285's for the reconcile's calls; the lists of D2 carry the context too. A create the deadline cuts got no answer and is retried under the same key, which returns what it made.
|
||||
- **A checkout's customer create without the request's context** is open question 2, and the Stripe public-details reads are a comment on #313; neither is built here.
|
||||
- **The table's name.** The outbox requirement this change modifies names `integration.integration_outbox`; the table is `core.outbox` (`internal/db/migrations/00001_init.sql:1083`). The modified requirement names it correctly; requirements this change does not modify keep their text.
|
||||
- **The second line under Retry** (C3).
|
||||
|
||||
### D7. The documents
|
||||
|
||||
`docs/stripe.md`, "How it works" (`:203`), gains the stop, the requeue key and the lookup after its sentence on `dead_letter`. `docs/models/payments-billing.md` (`:39`, the outbox drainer) says the same in one clause. `docs/testing.md` is unchanged: the tests answer through the mock's `Handle`. `docs/database-locks.md` is unchanged (Locks and transactions). `design/` is not edited.
|
||||
|
||||
## Every outbox kind and Stripe answer
|
||||
|
||||
"Keyed creates" are `create_stripe_product`, `create_stripe_price` and `create_stripe_customer`.
|
||||
|
||||
| Stripe's answer (after stripe-go's own retries) | Keyed creates today | Keyed creates after | `reconcile_subscription` (unchanged) |
|
||||
|---|---|---|---|
|
||||
| Success | mapping `synced`, entry `completed` | the same; a requeued entry that finds the object maps it without a create | `completed` |
|
||||
| 400, 401, 402, 403, 404, 422 | dead-letter at once (`StripeRequestRejected`) | unchanged | unchanged |
|
||||
| 409 (the key's first request still executing) | retried; a later send gets the saved answer | unchanged | retried |
|
||||
| 429 | retried | unchanged | retried |
|
||||
| Stripe's error object, 500 or above | retried about 150 times over a day, each answered with the saved error; dead-letter when the budget is spent; Retry sends the same key | dead-letter after one attempt with C1's reason; Retry counts the requeue, looks, then creates under the new key | retried; each attempt is a new call that fetches again |
|
||||
| A gateway's error page (no Stripe error object) | stripe-go retries twice, then the outbox for a day | unchanged | unchanged |
|
||||
| No answer (deadline, network fault) | retried; the create can run about four minutes past its attempt | retried; the create ends at the attempt's deadline (D6) | retried |
|
||||
|
||||
## Failure table
|
||||
|
||||
| # | State | Today | After | Test |
|
||||
|---|---|---|---|---|
|
||||
| F1 | Stripe answers a product, price or customer create with its own 500 | retried for a day under the saved 500; the panel reads "syncing" all day; dead-letter at the spent budget | non-retryable `StripeOutcomeUnknown` at the first attempt; the row reads `failed`, one attempt, C1's reason; no mapping; the workflow dead-letters it | `TestAKeyedCreateAnsweredWithAServerErrorStops`, `TestOutboxEntryWorkflowDeadLettersAnUnknownOutcome` |
|
||||
| F2 | Stripe answers 503 with its own error object | as F1 | as F1 | `TestStopOnSavedServerError/Stripe's_own_503` |
|
||||
| F3 | A gateway's error page | retried | unchanged | `TestStopOnSavedServerError/a_gateway's_error_page` |
|
||||
| F4 | No answer | retried; the create outlives its attempt | retried; the create carries the attempt's deadline | `TestStopOnSavedServerError/no_answer`, `TestOutboxCreatesCarryTheAttemptsContext` |
|
||||
| F5 | Stripe refuses the create, or answers 409 | refusal dead-letters at once, 409 retried | unchanged | `TestStopOnSavedServerError/a_refusal`, `.../a_conflict`; `TestClassifyStripeError`, unchanged |
|
||||
| F6 | A `reconcile_subscription` call answered 500 | retried | unchanged | `TestClassifyStripeError`, unchanged |
|
||||
| F7 | The operator presses Retry on a dead-lettered sync | the rows go back with the same key | the rows go back with `requeues` one more; two presses count once per dead-letter | `TestRetryCountsTheRequeue` |
|
||||
| F8 | A requeued entry's key | the same key | the old key with `-<requeues>` | `TestARequeuedEntrySendsANewKey`, `TestARequeuedCreateLooksBeforeItCreates/nothing_made` |
|
||||
| F9 | A requeued product or price entry; Stripe made the object | (no lookup) | listed, mapped, entry completed, no create | `.../a_product_Stripe_made`, `.../a_price_Stripe_made`, `.../a_one-time_price_Stripe_made`, `.../a_price_the_console_stores_in_capitals` |
|
||||
| F10 | A requeued entry; Stripe made nothing | the saved 500 again | created under the new key | `.../nothing_made` |
|
||||
| F11 | A requeued entry; Stripe stamped the object up to an hour before the row | (no lookup) | mapped | `.../a_product_Stripe_stamped_before_the_row` |
|
||||
| F12 | A requeued entry; an object carries the id but is archived, older than the window, or (a price) has another amount, currency or interval, or belongs to another Stripe product; or an object in the window carries another console id | (no lookup) | not mapped; created | `.../an_archived_product`, `.../a_product_made_before_the_window`, `.../another_product_in_the_window`, `.../an_archived_price`, `.../a_price_with_another_amount`, `.../a_price_in_another_currency`, `.../a_price_with_another_interval`, `.../a_price_of_another_product`, `.../another_price_in_the_window` |
|
||||
| F13 | Two products carry the entry's id | (no lookup) | the oldest mapped; the other logged | `.../two_products_Stripe_made` |
|
||||
| F14 | The lookup's list fails with a server error | (no lookup) | retryable; nothing created | `.../the_list_fails` |
|
||||
| F15 | An entry never requeued | the create under its key | unchanged; no list | `.../a_first_send`, `.../a_first_price_send` |
|
||||
| F16 | An entry in flight at the deploy | | `requeues` 0, the same key; its next attempt follows D1 | `TestARequeuedEntrySendsANewKey` (the format of a never-requeued key) |
|
||||
| F17 | The product's create stops; its price waits | the product retried a day too | the price retries "not yet synced" for up to a day, an ERROR line per attempt (#304); after Retry it lands at its next backoff, up to ten minutes | none (unchanged code) |
|
||||
|
||||
## Locks and transactions
|
||||
|
||||
No lock is added or moved. Against `docs/database-locks.md`:
|
||||
|
||||
- **Retry's update** runs on the plain handle before `Sync` takes the product-sync lock, as today; the one statement gains `requeues = requeues + 1`. Under READ COMMITTED a second press's update re-evaluates `status = 'dead_letter'` after the first commits and matches nothing, so a requeue counts once.
|
||||
- **The lookup** is a Stripe call made in the activity outside every transaction, holding no lock.
|
||||
- **The found object's mapping** is written by the same `UpsertProductMapping` or `UpsertPriceMapping`, followed by the same `completed` update, that a created object's is, each a statement of its own outside a transaction, as today. A process that dies between them leaves the row `processing`; the boot sweep runs it again, and the next attempt finds the object again.
|
||||
- **The migration** adds a column with a constant default, which Postgres records without rewriting the table; it takes `core.outbox`'s ACCESS EXCLUSIVE lock for that moment, at boot.
|
||||
- **The workflow code is unchanged** apart from comments, so running executions replay as before. `OutboxEntry` gains a field that a running execution's input decodes as zero; the activity reads the count from the row.
|
||||
|
||||
## Tests
|
||||
|
||||
Each test claim is pinned by a break (`docs/testing.md`, "Prove a test with deliberate breaks"); the tasks list each break with the text its failing test prints. The executor tests drive `stripetest.MockBackend` through a `Handle` that answers the catalog endpoints as Stripe would: a create makes an active object stamped now with the form's metadata, or answers Stripe's own 500; a list answers the objects that pass the form's `active`, `created[gte]` and `product`, newest first. The entry under test is passed to `ExecuteOutboxEntry` as its id alone, so the requeue count and the creation time come from the row, as in production. The workflow test mocks the activity, as the existing outbox workflow tests do.
|
||||
|
||||
## What is unverified
|
||||
|
||||
- **That Stripe saves a status above 500 that carries its own error object.** Stripe's documents name 500; the console treats every Stripe error object of 500 or above alike, as `docs/stripe.md` and the plan change already do. A server error Stripe did not save costs a Retry, which looks first.
|
||||
- **The ActivityError prefix of the stored reason.** Read from the Temporal SDK v1.38.0 (`internal/error.go:811-817`); the cause chain after it was observed through the SDK's default failure converter.
|
||||
- **That the list shows an object a request answered with 500 made.** Stripe documents its list APIs as free of search's delays; it also says recovery may make the object later (Risks).
|
||||
- **That Stripe's list filters behave as the test's `Handle` does** (`active`, `created[gte]`, `product`): read from the API reference.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Stripe may make the object after Retry's lookup found none**, while recovering from the incident, and the requeued create then makes a second one. Stripe's guidance is to resolve the outcome before a new key; the Retry is the operator's act, so it waits for the operator, and the second object is the one mapped while the first stays unmapped. Open question 1 would see the first one's event. The stop shows Sync failed within seconds, during the incident, so the first Retry now comes when Stripe is most likely still reconciling; a hold before a requeued create is a new rule and is left to its own issue, with open question 1.
|
||||
- **A server error Stripe did not save**, raised before the endpoint began, now stops an entry that a retry under the same key would have finished; it costs one Retry.
|
||||
- **The window maps an object made for the same ids by another database on the same Stripe account within the hour before the row**, which happens only with fixed seed ids or a restore. A price must also match the amount, currency and interval.
|
||||
- **One list call per attempt of a requeued entry**, under permissions the environment check already needs.
|
||||
|
||||
## Open questions
|
||||
|
||||
Each is a defect outside #312, so each is its own issue (`CONTRIBUTING.md`, "How changes are made", item 1), filed as #314 and #315 in this order. The statements follow the forge's bug form (`.gitea/ISSUE_TEMPLATE/bug.yaml`); none is built here. The readiness panel's share of the stored reason's raw chain is #149's, and goes there as a comment, as does the Stripe public-details reads' share of the missing context, on #313 (item 7.5 of the tasks).
|
||||
|
||||
### 1. A product or price that Stripe makes after answering the console with a server error is never recorded from Stripe's event (#314)
|
||||
|
||||
- **What happens:** The console records which Stripe product or price each of its products and prices is (a mapping). `openspec/specs/stripe-product-catalog-sync/spec.md` (`:115-118`, `:139-142`) requires that when a `product.created` or `price.created` event is processed and no mapping exists for the object's Stripe id, the handler upserts a mapping row with `sync_status = 'synced'`. Today the handlers (`webhook.go:491-495`, `:601-605`) log the object as possibly made outside the console and drop the event, because they read no console id; only the object's metadata, `product_id` or `price_id`, carries one, and the event the console stores keeps that metadata. Stripe may make an object while it recovers from an incident, after answering the console's create with a server error, and then sends that event.
|
||||
- **What should happen:** The handler reads the object's metadata `product_id` or `price_id` and maps the object when the console's mapping for it is pending with no Stripe id. A product or price the console already records with a Stripe id keeps that id. Stripe advises this after a server error: reconcile from its events by an identifier the integration puts in the metadata.
|
||||
- **Where:** `internal/integrations/stripe/workflows/webhook.go` (`handleProductEvent`, `handlePriceEvent`).
|
||||
- **Why it matters:** A product Stripe made stays unrecorded, and cannot be bought, until an operator presses Find or create in Stripe on its page, although Stripe's event already named it.
|
||||
|
||||
### 2. A checkout can create a second Stripe customer for an organization when Stripe answers after the request has ended (#315)
|
||||
|
||||
- **What happens:** A checkout creates the organization's Stripe customer and then its checkout session without the request's context, so the request's 32-second timeout does not stop either: each runs under the Stripe library's own 80-second timeout and two retries. A customer Stripe makes after the timeout is never recorded, because the console writes its record under the ended request.
|
||||
- **What should happen:** Both calls carry the request's context (`params.Context`), as the subscription reconcile's calls (#285) and the plan change's calls (#295) do.
|
||||
- **Where:** `internal/server/billing.go` (`createStripeCustomer`, `HandleCheckout`).
|
||||
- **Why it matters:** When Stripe is slow, a member's checkout can create a Stripe customer after the request has ended; the console's record of it is written under the ended request and fails, so the member's next checkout creates a second customer.
|
||||
@@ -0,0 +1,57 @@
|
||||
## Why
|
||||
|
||||
The Stripe outbox (`internal/integrations/stripe/workflows/outbox.go`) carries the product and price creates that Sync to Stripe starts on a product's page. Each entry runs in a Temporal workflow of its own and is retried for a day (`retry_budget.go`, `outboxRetryBudget`), and each create carries the entry's idempotency key, the same on every attempt (`outboxIdempotencyKey`). Stripe saves its answer to a key once it has begun executing the request, a status of 500 included, and answers every later request with that key from the saved answer for at least 24 hours (`docs/stripe.md`, "How it works").
|
||||
|
||||
`classifyStripeError` (`outbox.go:296-310`) leaves a 500 retryable. A create Stripe answered with its own 500 is therefore sent about 150 more times over the day, each time answered with the same saved 500, and is then dead-lettered. All that day the readiness panel's Payment processing row reads "The price is syncing to Stripe; this clears once the sync lands." (`internal/server/product_readiness.go:510-513`), and the product cannot be bought. After the dead-letter, Retry (`internal/integrations/stripe/catalogsync/catalogsync.go:142-154`) puts the same row back with the same id and creation time, so it sends the same key and, while Stripe keeps the key, gets the same 500.
|
||||
|
||||
A 500 does not say whether Stripe made the object. Stripe's guidance is to treat it as indeterminate and not to send the create under a new key until the outcome is resolved, by retrieving the object (Stripe, "Advanced error handling", Server errors). Sending it again under a new key without looking could make a second product or price.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **A create Stripe answers with its own error object and a status of 500 or above stops at once.** The product, price and customer creates pass the create's error through `stopOnSavedServerError`, which returns it non-retryable with the type `StripeOutcomeUnknown` and the reason "Stripe answered with a server error; whether it created the price is unknown" (`product`, `customer` for those entries). The workflow dead-letters the entry after its first attempt. A gateway's error page, a deadline and a network fault, which stripe-go returns as plain errors, stay retryable, because Stripe saved no answer for them. The `reconcile_subscription` action is not touched: it sends no key of the entry's own, so a 500 on its call stays retryable.
|
||||
- **Retry counts the requeue, and the key changes with it.** A migration adds `core.outbox.requeues INTEGER NOT NULL DEFAULT 0`. Retry's update adds one to it. `outboxIdempotencyKey` appends `-<requeues>` when it is above zero, so an entry never requeued keeps the key it has always had, and each requeue sends a key no earlier send spent.
|
||||
- **A requeued entry looks for the object before it creates one.** It lists, through Stripe's list API and not its search, the active products (for a price, the active prices of the product's Stripe product) created since one hour before the entry's row was written, and keeps those whose metadata carries the console's id; a price must also have the entry's amount, currency and interval. It maps the oldest and marks the entry done, logging any other; when none matches, it creates the object under the requeue's key. Every create already writes the console's id into the object's metadata.
|
||||
- **The three creates carry the attempt's context**, as the subscription reconcile's calls do since #285. #312 needs it: Retry matches only dead-lettered rows, which must have nothing in flight, and a create without the context outlives its attempt by up to about four minutes.
|
||||
- **Ride-alongs, listed under `CONTRIBUTING.md`, "How changes are made", item 1:** the modified outbox requirement names the table `core.outbox` in place of the stale `integration.integration_outbox`; the readiness panel drops the second line under its Retry control, which repeats the row above it.
|
||||
- **Copy** (design.md, "Copy table", approved): the stored reason above; the Payment processing row's closing sentence after a failed sync, "Use Retry to run it again.", removed; the Retry control's label "Retry Stripe sync" becoming "Find or create in Stripe".
|
||||
- **Docs:** `docs/stripe.md` ("How it works") and `docs/models/payments-billing.md` (the outbox drainer) describe the stop, the requeue's key and the lookup.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **A lookup at the stop.** The entry is dead-lettered at once and looks only when an operator requeues it (design D2).
|
||||
- **Mapping an object from Stripe's event.** The main spec already requires the handlers to map an object that arrives before the console's own answer; they drop it. A defect, open question 1.
|
||||
- **The dead-letter reason's raw chain.** The stored reason is the workflow engine's error chain around the executor's sentence, for every dead-letter; #149 covers it, and the readiness panel's Payment processing row shows the same text.
|
||||
- **A checkout's customer create without the request's context** (`internal/server/billing.go`): open question 2. The Stripe public-details reads (`internal/integrations/stripe/workflows/public_details.go`) go to #313 as a comment.
|
||||
- **A hold before a requeued create.** Retry now comes seconds after the stop, during an incident; a wait is a new rule (design Risks).
|
||||
- **A Retry for customers.** No code enqueues `create_stripe_customer`, and Retry matches product and price entries only; a customer create shares the stop and nothing else.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
None.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `stripe-integration-infrastructure`: "Integration outbox supports transactional outbound actions" gains the stop on Stripe's own server error to a keyed create, the requeue count and the key that carries it.
|
||||
- `stripe-product-catalog-sync`: "Outbox executor creates Stripe Product and writes mapping" and "Outbox executor creates Stripe Price and writes mapping" gain the lookup of a requeued entry and the stop.
|
||||
- `product-management`: "Operator can sync a product to Stripe from the readiness panel" has Retry count the requeue.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Code:** `internal/integrations/stripe/workflows/outbox.go` (the stop, the key, the lookup, the three creates), `internal/integrations/stripe/catalogsync/catalogsync.go` (Retry's update), `internal/server/product_readiness.go` and `internal/embeds/templates/partials/operator_product_readiness.html` (copy).
|
||||
- **Schema:** `internal/db/migrations/00039_outbox_requeues.sql` adds one column with a constant default; no row is rewritten and no key of an existing entry changes.
|
||||
- **Stripe calls:** a requeued entry sends `GET /v1/products` or `GET /v1/prices` before its create, under the Products and Prices read permissions the environment check already uses. The creates carry the attempt's context.
|
||||
- **Behaviour a deployment sees:** a product whose create Stripe answers with a server error shows Sync failed at once, with the reason, instead of syncing for a day; Retry maps what Stripe made and creates the rest under a new key.
|
||||
- **Tests:** new tests in `internal/integrations/stripe/workflows` and `internal/server`; one golden and the render test's line for the Retry label change with the approved copy, and `internal/migrate/testdata/console-reader-columns.txt` gains the new column's `read` mark.
|
||||
- **Docs:** `docs/stripe.md`, `docs/models/payments-billing.md`.
|
||||
|
||||
## Related issues
|
||||
|
||||
- **#312** is this change; it closes with it.
|
||||
- **#147** gave the key the row's creation time; an entry never requeued keeps that key unchanged.
|
||||
- **#285** set the rule that a Stripe call carries its caller's context, which this change applies to the three creates.
|
||||
- **#304** logs each failed attempt at ERROR; a product's price waits that way after the product's create stops.
|
||||
- **#183** moves Retry's hand-written SQL in `catalogsync` into the outbox's package.
|
||||
- **#149** shows a dead-lettered entry's stored reason as the raw error chain; the readiness panel shows the same text, which the stop's reason now reaches within seconds.
|
||||
- **#313** the environment check's reads, the same missing context; the public-details reads have the same shape and go there as a comment, and open question 2 covers the checkout's customer create.
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Operator can sync a product to Stripe from the readiness panel
|
||||
|
||||
The operator product surface SHALL expose an action
|
||||
`POST /partials/operator/products/{productID}/sync-stripe` that drives a product and
|
||||
its active price to Stripe-mapped state by enqueuing the catalog sync, and that
|
||||
doubles as the **retry** for a terminally-failed sync. When invoked it SHALL:
|
||||
|
||||
- Reject with an explanatory message and enqueue nothing when Stripe is not configured
|
||||
for the deployment (the deployment has no Stripe API key, read as the mode the console derives from it at
|
||||
boot; Requirement: Operator product edit page surfaces purchasability readiness).
|
||||
- Reject with guidance to add a price, and enqueue nothing, when the product has no
|
||||
active price.
|
||||
- Be idempotent for in-flight and completed syncs in the current environment: when
|
||||
the active price's mapping records the key's environment or records none, and is
|
||||
not `stale`, or a product- or price-mapping is `pending` with no terminally-failed
|
||||
outbox entry, it SHALL NOT enqueue a duplicate sync (so repeat invocations do not
|
||||
create duplicate Stripe objects) and SHALL refuse with
|
||||
`This price is already synced to Stripe.`
|
||||
- **Create the object again when the key cannot reach it:** when the active price's
|
||||
mapping records the other environment, or carries `sync_status = 'stale'`, the
|
||||
refusal SHALL NOT fire; the action SHALL write the mappings and enqueue
|
||||
`create_stripe_product` and `create_stripe_price` as it does for a never-synced
|
||||
product, and the mapping the executor writes replaces the unreachable id. The
|
||||
control SHALL be labelled `Create in live` or `Create in test` in that case,
|
||||
naming the environment the key is in, with no confirm modal: the modal is the
|
||||
destructive idiom and creating an object in the current environment destroys
|
||||
nothing, while the readiness row beside the control states why the old id is
|
||||
unreachable.
|
||||
- **Retry a terminally-failed sync:** when the product's `create_stripe_product` /
|
||||
`create_stripe_price` outbox entries have dead-lettered, it SHALL re-drive them
|
||||
(reset the dead-lettered entries to `pending` and add one to each one's `requeues`,
|
||||
so the outbox drains them again, each looks in Stripe for the object an earlier
|
||||
send made before it creates one, and a create is sent under a new idempotency key
|
||||
rather than the key an earlier send spent; `stripe-product-catalog-sync`)
|
||||
rather than treating the still-`pending` mapping as an in-flight no-op. The control
|
||||
SHALL be labelled `Find or create in Stripe`.
|
||||
- Otherwise (never synced) write `stripe.product_mappings` and `stripe.price_mappings`
|
||||
rows with `sync_status = 'pending'` and enqueue both a `create_stripe_product` and a
|
||||
`create_stripe_price` outbox entry, so the readiness panel immediately reflects
|
||||
**sync pending** and the mappings land when the outbox drains.
|
||||
|
||||
Creating a price SHALL NOT by itself enqueue a Stripe sync; the explicit
|
||||
Sync-to-Stripe action is the single operator-driven sync trigger.
|
||||
|
||||
#### Scenario: Sync action enqueues product and price and shows pending
|
||||
|
||||
- **WHEN** an operator invokes the Sync-to-Stripe action for a product that has an
|
||||
active price, is not yet mapped, and for which no sync is pending, with Stripe
|
||||
configured
|
||||
- **THEN** a `stripe.product_mappings` row and a `stripe.price_mappings` row SHALL be
|
||||
written with `sync_status = 'pending'`
|
||||
- **AND** a `create_stripe_product` and a `create_stripe_price` outbox entry SHALL be
|
||||
enqueued
|
||||
- **AND** the re-rendered readiness panel SHALL show the Stripe-mapped precondition as
|
||||
**sync pending**
|
||||
|
||||
#### Scenario: Sync action is a no-op when already pending (in-flight) or synced
|
||||
|
||||
- **WHEN** an operator invokes the Sync-to-Stripe action for a product whose price is
|
||||
already mapped in the key's environment or in none, and not `stale`, or whose
|
||||
product/price mapping is `pending` with no dead-lettered outbox entry
|
||||
- **THEN** no additional outbox entry SHALL be enqueued and no duplicate Stripe object
|
||||
SHALL be created
|
||||
- **AND** the action SHALL answer `This price is already synced to Stripe.`
|
||||
|
||||
#### Scenario: Sync creates the object again when the mapping is unreachable
|
||||
|
||||
- **WHEN** an operator invokes the Sync-to-Stripe action for a product whose active
|
||||
price carries a mapping recorded in the environment the key is not in, or one
|
||||
marked `stale`
|
||||
- **THEN** the action SHALL NOT refuse; it SHALL write the mappings and enqueue
|
||||
`create_stripe_product` and `create_stripe_price`, the readiness panel SHALL show
|
||||
**sync pending**, and the mapping the executor writes SHALL replace the
|
||||
unreachable id
|
||||
- **AND** the control SHALL be labelled `Create in live` under a live key and
|
||||
`Create in test` under a test key, and SHALL open no confirm modal
|
||||
|
||||
#### Scenario: Retry re-drives a dead-lettered sync
|
||||
|
||||
- **WHEN** an operator invokes the Sync-to-Stripe action for a product whose
|
||||
`create_stripe_product` / `create_stripe_price` outbox entries have dead-lettered
|
||||
- **THEN** those dead-lettered entries SHALL be reset to `pending` (so the outbox
|
||||
re-processes them) rather than the action no-op'ing on the stuck `pending` mapping
|
||||
- **AND** each of those entries' `requeues` SHALL be one more than before, and a
|
||||
second invocation before the entries are dead-lettered again SHALL NOT add to it
|
||||
- **AND** the re-rendered readiness panel SHALL show the Stripe-mapped precondition
|
||||
back at **sync pending**
|
||||
|
||||
#### Scenario: Sync action rejected when Stripe is not configured
|
||||
|
||||
- **WHEN** the Sync-to-Stripe action is invoked while the deployment's Stripe
|
||||
credentials are not set
|
||||
- **THEN** it SHALL render an explanatory message and SHALL enqueue nothing
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Integration outbox supports transactional outbound actions
|
||||
The system SHALL provide a `core.outbox` table that captures domain state changes requiring external action, with at-least-once delivery guarantees. Each entry SHALL carry the time it is due, `next_attempt_at`; an entry written without one is due at once. Retries SHALL be Temporal's: each entry is executed by a workflow of its own whose activity is retried on Temporal's schedule within a budget of one day, and the entry SHALL be dead-lettered when the budget is spent, when the provider answers that the request itself is wrong, or when Stripe answers a create sent under the entry's idempotency key with its own error object and a status of 500 or above.
|
||||
|
||||
Each entry SHALL carry `requeues`, the number of times an operator's Retry sent it from `dead_letter` back to `pending`, zero for an entry never requeued. A Stripe create SHALL carry the idempotency key `member-console-outbox-<id>-<the row's creation time in microseconds>`, followed by `-<requeues>` when `requeues` is above zero, so that every attempt of one run sends the same key, every entry that ever existed in any database sends a key of its own, and each requeue sends a key no earlier send of the entry used.
|
||||
|
||||
A create of a product, a price or a customer that Stripe answers with its own error object and a status of 500 or above SHALL end the entry at that attempt, because Stripe saves that answer under the key and answers every later send with the key from it, and the answer does not say whether Stripe made the object. The entry's dead-letter reason SHALL say that whether Stripe created the object is unknown. An error that carries no error object from Stripe (a deadline, a network fault, or a gateway's error page, which the Stripe library returns as a plain error) SHALL stay retryable, because Stripe saved no answer for it. A `reconcile_subscription` entry sends no key of its own, and a server error on its Stripe call SHALL stay retryable.
|
||||
|
||||
#### Scenario: Pending outbox entry is dispatched
|
||||
- **WHEN** an outbox entry exists with status `pending` and its `next_attempt_at` has passed
|
||||
- **THEN** the relay hands it to a workflow keyed by the entry id, and a second dispatch of a running entry is a no-op
|
||||
|
||||
#### Scenario: An entry not yet due waits
|
||||
- **WHEN** an outbox entry with status `pending` has a `next_attempt_at` in the future
|
||||
- **THEN** neither the relay nor the boot sweep takes it before that time
|
||||
|
||||
#### Scenario: Failed outbox entry is retried by Temporal
|
||||
- **WHEN** an outbox entry's execution fails retryably
|
||||
- **THEN** the row reads `failed` with Temporal's attempt number and the error, and Temporal runs the execution again after its backoff
|
||||
|
||||
#### Scenario: Exhausted or rejected entry moves to dead letter
|
||||
- **WHEN** an entry fails through the whole budget, or the provider rejects the request as wrong (a 4xx other than a rate limit or a conflict)
|
||||
- **THEN** the system transitions the entry status to `dead_letter` for manual investigation and the entry's workflow fails
|
||||
|
||||
#### Scenario: A retry never creates a second provider object
|
||||
- **WHEN** an attempt created the provider object but its result was lost and Temporal runs the attempt again
|
||||
- **THEN** the create carries the same idempotency key, derived from the entry id, the row's creation time and its requeue count, and the provider returns the first attempt's object; an entry already `completed` is not executed again
|
||||
|
||||
#### Scenario: A create Stripe answered with its own server error stops at once
|
||||
- **WHEN** Stripe answers a product, price or customer create with its own error object and a status of 500
|
||||
- **THEN** the attempt returns a non-retryable error, the row reads `failed` with one attempt and a reason saying that whether Stripe created the object is unknown, no mapping is written, and the workflow dead-letters the entry without a second attempt
|
||||
|
||||
#### Scenario: A gateway's error page is retried
|
||||
- **WHEN** a create's answer is a gateway's error page with no Stripe error object, or the create gets no answer before its deadline
|
||||
- **THEN** the attempt's error stays retryable and Temporal runs the entry again under the same key
|
||||
|
||||
#### Scenario: A requeued entry sends a new key
|
||||
- **WHEN** an entry whose key was `member-console-outbox-7-1790499600123456` is requeued once
|
||||
- **THEN** its creates carry `member-console-outbox-7-1790499600123456-1` on every attempt, and an entry never requeued keeps the key without a suffix
|
||||
|
||||
#### Scenario: A reconcile's server error is retried
|
||||
- **WHEN** Stripe answers the call of a `reconcile_subscription` entry with a status of 500
|
||||
- **THEN** the attempt's error stays retryable, and the next attempt reads the subscription again before it decides
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Outbox executor creates Stripe Product and writes mapping
|
||||
|
||||
The system SHALL implement a `create_stripe_product` outbox action executor that calls the Stripe API, creates the product, and writes the mapping row, stamping the row with the `livemode` flag carried by the Stripe Product the create call returned. The create SHALL carry the console's product id in the product's metadata as `product_id`, and the attempt's context.
|
||||
|
||||
An entry an operator requeued (`requeues` above zero) SHALL first look for the product an earlier send made: it SHALL list, through Stripe's list API and not its search, the active Stripe products created at or after one hour before the entry's row was written, and SHALL keep those whose metadata `product_id` is the entry's product. When one or more match, the executor SHALL map the oldest by Stripe's creation time, stamping `livemode` from it, SHALL make no create, and SHALL log each other match with its Stripe id. When none matches, it SHALL create the product under the entry's idempotency key. An entry never requeued SHALL NOT list. A list that fails SHALL fail the attempt, and nothing SHALL be created after it.
|
||||
|
||||
A create that Stripe answers with its own error object and a status of 500 or above SHALL write no mapping row, and SHALL stop the entry at once with a reason saying that whether Stripe created the product is unknown (`stripe-integration-infrastructure`).
|
||||
|
||||
#### Scenario: Successful Stripe Product creation
|
||||
|
||||
- **WHEN** the outbox executor processes a `create_stripe_product` entry
|
||||
- **THEN** the executor calls Stripe POST /v1/products, receives a `prod_xxx` ID, and inserts a `stripe.product_mappings` row with `sync_status = 'synced'` and `livemode` copied from the returned product, never derived from the API key
|
||||
|
||||
#### Scenario: Stripe API failure triggers retry
|
||||
|
||||
- **WHEN** the Stripe API returns an error during product creation other than Stripe's own error object with a status of 500 or above
|
||||
- **THEN** the outbox entry follows the standard retry/backoff/dead-letter pattern without writing a mapping row
|
||||
|
||||
#### Scenario: Stripe's own server error stops the entry
|
||||
|
||||
- **WHEN** Stripe answers the product create with its own error object and a status of 500
|
||||
- **THEN** no mapping row is written and the entry is dead-lettered after that attempt with a reason saying that whether Stripe created the product is unknown
|
||||
|
||||
#### Scenario: A requeued entry maps the product an earlier send made
|
||||
|
||||
- **WHEN** a requeued `create_stripe_product` entry runs and Stripe holds an active product created after the entry's row was written whose metadata `product_id` is the entry's product
|
||||
- **THEN** the executor maps that product with `sync_status = 'synced'` and its `livemode`, marks the entry `completed`, and sends no create
|
||||
|
||||
#### Scenario: A requeued entry that finds nothing creates under its new key
|
||||
|
||||
- **WHEN** a requeued `create_stripe_product` entry runs and no active product created since an hour before the row carries its product id
|
||||
- **THEN** the executor creates the product under the key that ends with the entry's requeue count and maps it
|
||||
|
||||
#### Scenario: An object that is not the entry's is not mapped
|
||||
|
||||
- **WHEN** the only product carrying the entry's product id is archived or was created more than an hour before the entry's row, or the only product in the window carries another product id
|
||||
- **THEN** the executor maps none of them and creates the product
|
||||
|
||||
#### Scenario: The oldest of several is mapped
|
||||
|
||||
- **WHEN** two active products in the window carry the entry's product id
|
||||
- **THEN** the executor maps the one Stripe created first and logs the other's Stripe id
|
||||
|
||||
### Requirement: Outbox executor creates Stripe Price and writes mapping
|
||||
|
||||
The system SHALL implement a `create_stripe_price` outbox action executor that resolves the Stripe Product ID, calls the Stripe API, creates the price, and writes the mapping row, stamping the row with the `livemode` flag carried by the Stripe Price the create call returned. The executor SHALL have a soft dependency on the product being synced. The create SHALL carry the console's price id in the price's metadata as `price_id`, beside `product_id`, and the attempt's context.
|
||||
|
||||
An entry an operator requeued (`requeues` above zero) SHALL first look for the price an earlier send made: once the product's mapping is synced, it SHALL list, through Stripe's list API and not its search, the active prices of the mapped Stripe product created at or after one hour before the entry's row was written, and SHALL keep those whose metadata `price_id` is the entry's price and whose unit amount, currency and recurring interval are the entry's. When one or more match, the executor SHALL map the oldest by Stripe's creation time, stamping `livemode` from it, SHALL make no create, and SHALL log each other match with its Stripe id. When none matches, it SHALL create the price under the entry's idempotency key. An entry never requeued SHALL NOT list. A list that fails SHALL fail the attempt, and nothing SHALL be created after it.
|
||||
|
||||
A create that Stripe answers with its own error object and a status of 500 or above SHALL write no mapping row, and SHALL stop the entry at once with a reason saying that whether Stripe created the price is unknown (`stripe-integration-infrastructure`).
|
||||
|
||||
#### Scenario: Successful Stripe Price creation
|
||||
|
||||
- **WHEN** the outbox executor processes a `create_stripe_price` entry and a `synced` product mapping exists for the referenced `product_id`
|
||||
- **THEN** the executor calls Stripe POST /v1/prices with the resolved `stripe_product_id`, receives a `price_xxx` ID, and inserts a `stripe.price_mappings` row with `sync_status = 'synced'` and `livemode` copied from the returned price, never derived from the API key
|
||||
|
||||
#### Scenario: Product not yet synced causes retriable failure
|
||||
|
||||
- **WHEN** the outbox executor processes a `create_stripe_price` entry and no `synced` product mapping exists for the referenced `product_id`
|
||||
- **THEN** the executor returns a retriable error and the outbox entry is retried with standard backoff
|
||||
|
||||
#### Scenario: Stripe API failure triggers retry
|
||||
|
||||
- **WHEN** the Stripe API returns an error during price creation other than Stripe's own error object with a status of 500 or above
|
||||
- **THEN** the outbox entry follows the standard retry/backoff/dead-letter pattern without writing a mapping row
|
||||
|
||||
#### Scenario: Stripe's own server error stops the entry
|
||||
|
||||
- **WHEN** Stripe answers the price create with its own error object and a status of 500
|
||||
- **THEN** no price mapping row is written and the entry is dead-lettered after that attempt with a reason saying that whether Stripe created the price is unknown
|
||||
|
||||
#### Scenario: A requeued entry maps the price an earlier send made
|
||||
|
||||
- **WHEN** a requeued `create_stripe_price` entry runs and the mapped Stripe product has an active price created after the entry's row was written whose metadata `price_id`, unit amount, currency and interval are the entry's
|
||||
- **THEN** the executor maps that price with `sync_status = 'synced'` and its `livemode`, marks the entry `completed`, and sends no create
|
||||
|
||||
#### Scenario: A price that differs is not mapped
|
||||
|
||||
- **WHEN** the only price in the window carrying the entry's price id has another unit amount, currency or interval, or is archived, or belongs to another Stripe product, or the only price in the window carries another price id
|
||||
- **THEN** the executor maps none of them and creates the price under the key that ends with the entry's requeue count
|
||||
@@ -0,0 +1,457 @@
|
||||
Tests follow `docs/testing.md`: database tests open the database through `dbtest`, build their rows through the test kit or the package's fixtures, filter to the rows their own fixture made, and pass twice in a row without a reset. A Stripe test drives `stripetest.MockBackend` with the API state Stripe would return, never a hand-built event payload, and runs without `t.Parallel()`. The executor tests answer Stripe's catalog endpoints through the mock's `Handle` (`catalogStripe`, item 5.3); Stripe's own server error is `&stripe.Error{Type: stripe.ErrorTypeAPI, HTTPStatusCode: 500, Msg: "server error"}`, and an entry under test is passed to `ExecuteOutboxEntry` as `OutboxEntry{ID: id}`, so its creation time and requeue count come from the row.
|
||||
|
||||
Sections 1 to 6 are the parts of one commit; section 7 is not a commit. The commit passes `make test` with the test stack up, which runs `make lint` first and then every package one at a time (`-p 1`). Three test files change: the golden `internal/server/testdata/operator_sync_stripe/readiness_panel_names_a_dead-lettered_sync.golden`, by the approved rows C2 and C3 of design.md's Copy table, regenerated with `-update` and read against the table before it is committed; the line `wantTextOf(t, doc, "#product-readiness button", "Retry Stripe sync")` of `internal/server/operator_product_readiness_render_test.go` (`:230`), by row C4, which is no golden's text; and `internal/migrate/testdata/console-reader-columns.txt` (item 2.1a). No other golden, route table or existing test result changes (item 5.10); any other change is a stop. `make sqlc-generate` changes no file, because no query reads the new column and the integration package omits unused structs; any change is a stop. The edits to existing test files are the recorder of item 5.2, the render test's line of item 4.3 and the new tests of items 5.1, 5.2, 5.7 and 5.8. A script's usage is learned from its header or source, never by running it.
|
||||
|
||||
**Breaks.** The list of item 5.11 is written to a file outside the repository and run with `go run ./scripts/mutation-check -list <file> -side new` on the commit, from the repository root, with `test/.env` loaded as `make test` loads it (without it the database tests skip and every record reports SKIPPED), after its green control run; the tests are new, so there is no old side. A break of Go code or a template is a test record (`name`, `file`, `old`, `new`, `pkg`, `run_new`, `want_new`). A break caught by two tests is two records, one per test, with the same `file`, `old` and `new`. Every record's `want_new` is a text the failing test prints only when it fails, never a test or subtest name alone. Every record must be CAUGHT. An `old` text below is the code as the section's items write it, with `\n` and `\t` standing for the line break and the tab gofmt writes; when the committed code words it differently, the record is corrected to the committed text before the run, never dropped, and its `new` text keeps the package compiling. A break that no test of the commit sees is named with the reason, and the commit message says so. The commit message lists every break with its result.
|
||||
|
||||
**Copy.** design.md's Copy table holds four approved rows. Items 4.1 to 4.3, the golden and the render test's line wait for that approval, and a row the maintainer rewords is applied in the approved words, with the break, the golden and the `product-management` delta that name it following. C1 is the reason of item 1.1; the rest of the change does not wait.
|
||||
|
||||
## 1. The stop
|
||||
|
||||
- [x] 1.1 `internal/integrations/stripe/workflows/outbox.go`, after `classifyStripeError` (design D1):
|
||||
|
||||
```go
|
||||
// outcomeUnknownErrorType is the Temporal error type of a create Stripe
|
||||
// answered with its own server error (stopOnSavedServerError).
|
||||
const outcomeUnknownErrorType = "StripeOutcomeUnknown"
|
||||
|
||||
// stopOnSavedServerError ends the retries of a create sent under the entry's
|
||||
// idempotency key when Stripe answered it with its own error object and a
|
||||
// status of 500 or above. Stripe saved that answer under the key once it
|
||||
// began executing the request and answers every later send with the key from
|
||||
// it, so no retry under the key can succeed; and a server error does not say
|
||||
// whether Stripe made the object. An error that is not Stripe's error object
|
||||
// (a deadline, a network fault, a gateway's error page, which stripe-go
|
||||
// returns as a plain error) reached no saved answer and is returned as it is,
|
||||
// as is every Stripe answer below 500, which classifyStripeError decides.
|
||||
// object names what the create makes, for the reason the entry is
|
||||
// dead-lettered with.
|
||||
func stopOnSavedServerError(object string, err error) error {
|
||||
var se *stripego.Error
|
||||
if !errors.As(err, &se) || se.HTTPStatusCode < 500 {
|
||||
return err
|
||||
}
|
||||
return temporal.NewNonRetryableApplicationError(
|
||||
fmt.Sprintf("Stripe answered with a server error; whether it created the %s is unknown", object),
|
||||
outcomeUnknownErrorType, err)
|
||||
}
|
||||
```
|
||||
|
||||
The three creates call it on their own error (items 3.4, 3.5, 3.6) and nothing else does. `classifyStripeError` is unchanged.
|
||||
|
||||
- [x] 1.2 The comments. The comment over the retry-budget constants (`outbox.go:59-65`) reads: "The retry budget for one outbox entry, the same as a webhook event's: Temporal retries the activity from one second, doubling to ten minutes, for a day; a Stripe answer that says the request itself is wrong (classifyStripeError), or Stripe's own server error to a create sent under the entry's key (stopOnSavedServerError), ends it at once; the spent budget dead-letters the entry for the Stripe provider page. Every Stripe create carries the entry's idempotency key, so a retry after a lost result reuses the object the first attempt made rather than making another." `ExecuteOutboxEntry`'s comment (`:232-238`) adds after "is returned non-retryable": "and so is Stripe's own server error to a create (stopOnSavedServerError)". `classifyStripeError`'s comment (`:296-299`) adds: "A create's own server error is stopped before this, by stopOnSavedServerError, because Stripe saved it under the create's key; a server error here is a reconcile's call or a lookup's list, each of whose next attempt is a new request."
|
||||
|
||||
## 2. The requeue count and the key
|
||||
|
||||
- [x] 2.1 `internal/db/migrations/00039_outbox_requeues.sql` (design D3), with the repository's SPDX header:
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
-- core.outbox.requeues counts the times an operator's Retry sent an entry
|
||||
-- from dead_letter back to pending (catalogsync.Sync). The Stripe outbox
|
||||
-- appends it to the entry's idempotency key, so a requeued create is not
|
||||
-- answered with what Stripe saved under the key an earlier send spent, a
|
||||
-- server error included, and looks for what that send made before it
|
||||
-- creates (#312). Zero is every existing row's count: a Retry before this
|
||||
-- column did not change the key, so every entry keeps the key it sends.
|
||||
ALTER TABLE core.outbox ADD COLUMN requeues INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE core.outbox DROP COLUMN requeues;
|
||||
```
|
||||
|
||||
- [x] 2.1a `internal/migrate/testdata/console-reader-columns.txt` gains `read core.outbox.requeues` between `read core.outbox.provider` and `read core.outbox.status`, written by `go test ./internal/migrate/ -run TestConsoleReaderColumns -count=1 -update` with `test/.env` loaded; the diff is that one line, marked `read` (a count, no personal data). `docs/database-management.md` requires the mark for every new column.
|
||||
|
||||
- [x] 2.2 `OutboxEntry` (`outbox.go:30-45`) gains, after `CreatedAt`:
|
||||
|
||||
```go
|
||||
// Requeues is how many times an operator's Retry sent the entry back
|
||||
// (catalogsync.Sync). The idempotency key carries it, and a requeued
|
||||
// entry looks for what an earlier send made before it creates
|
||||
// (findProduct, findPrice). ExecuteOutboxEntry reads it from the row,
|
||||
// as it reads CreatedAt.
|
||||
Requeues int `db:"requeues"`
|
||||
```
|
||||
|
||||
`CreatedAt`'s comment reads "because the idempotency key is derived from it and from Requeues".
|
||||
|
||||
- [x] 2.3 `ExecuteOutboxEntry`'s re-read (`outbox.go:241-245`):
|
||||
|
||||
```go
|
||||
if err := a.DB.QueryRowContext(ctx,
|
||||
`SELECT status, payload, action_type, created_at, requeues FROM core.outbox WHERE id = $1`, entry.ID,
|
||||
).Scan(&status, &entry.Payload, &entry.ActionType, &entry.CreatedAt, &entry.Requeues); err != nil {
|
||||
```
|
||||
|
||||
- [x] 2.4 `outboxIdempotencyKey` (`outbox.go:312-322`):
|
||||
|
||||
```go
|
||||
// outboxIdempotencyKey is the Stripe idempotency key for an entry's create:
|
||||
// the same key on every attempt, so a retry after a lost result returns the
|
||||
// object the first attempt made (Stripe keeps keys for 24 hours, the retry
|
||||
// budget). The id alone is not enough: it restarts with the outbox's
|
||||
// sequence after a database reset, a restore, or in a second database on
|
||||
// the same Stripe account, and Stripe refuses a key it has seen with other
|
||||
// parameters. The row's creation time, to the microsecond Postgres keeps,
|
||||
// tells those entries apart and never changes between attempts. A requeue
|
||||
// (an operator's Retry) appends its count: Stripe answers every send under
|
||||
// the key an earlier send spent with that send's saved answer, a server
|
||||
// error included, so the requeued entry sends a key no earlier send used,
|
||||
// once it has looked for what an earlier send made (findProduct,
|
||||
// findPrice). An entry never requeued keeps the key it always had.
|
||||
func outboxIdempotencyKey(entry OutboxEntry) string {
|
||||
key := fmt.Sprintf("member-console-outbox-%d-%d", entry.ID, entry.CreatedAt.UnixMicro())
|
||||
if entry.Requeues > 0 {
|
||||
key += fmt.Sprintf("-%d", entry.Requeues)
|
||||
}
|
||||
return key
|
||||
}
|
||||
```
|
||||
|
||||
- [x] 2.5 `internal/integrations/stripe/catalogsync/catalogsync.go`, Retry's update in `Sync` (`:143-152`):
|
||||
|
||||
```go
|
||||
if _, err := d.ExecContext(ctx,
|
||||
`UPDATE core.outbox
|
||||
SET status = 'pending', attempts = 0, requeues = requeues + 1, next_attempt_at = NOW(),
|
||||
error_message = NULL, updated_at = NOW()
|
||||
WHERE provider = 'stripe' AND status = 'dead_letter'
|
||||
AND ( (action_type = 'create_stripe_product' AND payload->>'product_id' = $1)
|
||||
OR (action_type = 'create_stripe_price' AND payload->>'price_id' = $2) )`,
|
||||
productID, price.PriceID); err != nil {
|
||||
```
|
||||
|
||||
`Sync`'s comment, after "rather than the stuck mapping answered as in progress.", adds: "Each re-driven entry counts the requeue, so its create looks in Stripe for what an earlier send made and is sent under a new idempotency key: the key an earlier send spent answers with that send's saved answer, a server error included." The comment on `Retrying` (`:29-31`) reads "a dead-lettered create of the product or the price was sent back to pending, its requeue counted."
|
||||
|
||||
## 3. The lookup and the creates
|
||||
|
||||
- [x] 3.1 `outbox.go`, after `outboxIdempotencyKey` (design D2); the file imports `cmp`, `slices` and `strings`:
|
||||
|
||||
```go
|
||||
// outboxLookupSkew is how far before the row's creation time a requeued
|
||||
// entry looks for an object an earlier send made. Every send came after the
|
||||
// row was written; the hour allows for a database clock ahead of Stripe's.
|
||||
const outboxLookupSkew = time.Hour
|
||||
|
||||
// lookupSince is the earliest creation time, in Stripe's Unix seconds, of an
|
||||
// object a send of entry can have made.
|
||||
func lookupSince(entry OutboxEntry) int64 {
|
||||
return entry.CreatedAt.Add(-outboxLookupSkew).Unix()
|
||||
}
|
||||
|
||||
// oldest is the first of found that Stripe created, by its creation time and
|
||||
// then its id, or nil when found is empty. Each other is logged: an object
|
||||
// an earlier send made that the console leaves unmapped, for an operator to
|
||||
// archive in the Dashboard.
|
||||
func oldest[T any](logger *slog.Logger, entry OutboxEntry, found []*T, idAt func(*T) (string, int64)) *T {
|
||||
if len(found) == 0 {
|
||||
return nil
|
||||
}
|
||||
slices.SortFunc(found, func(x, y *T) int {
|
||||
xi, xa := idAt(x)
|
||||
yi, ya := idAt(y)
|
||||
return cmp.Or(cmp.Compare(xa, ya), strings.Compare(xi, yi))
|
||||
})
|
||||
for _, other := range found[1:] {
|
||||
id, _ := idAt(other)
|
||||
logger.Warn("stripe outbox: an earlier send made another object for this entry; it stays unmapped",
|
||||
slog.Int64("entry_id", entry.ID), slog.String("stripe_id", id))
|
||||
}
|
||||
return found[0]
|
||||
}
|
||||
```
|
||||
|
||||
- [x] 3.2 `findProduct`:
|
||||
|
||||
```go
|
||||
// findProduct is the Stripe product an earlier send of a requeued entry
|
||||
// made, or nil: an active product created since lookupSince whose metadata
|
||||
// names the entry's product. It reads Stripe's list, which shows what Stripe
|
||||
// has written; Stripe's search lags its writes and is not offered to
|
||||
// accounts in India. The list carries the attempt's context.
|
||||
func (a *OutboxActivities) findProduct(ctx context.Context, entry OutboxEntry, productID string) (*stripego.Product, error) {
|
||||
params := &stripego.ProductListParams{
|
||||
Active: stripego.Bool(true),
|
||||
CreatedRange: &stripego.RangeQueryParams{GreaterThanOrEqual: lookupSince(entry)},
|
||||
}
|
||||
params.Context = ctx
|
||||
var found []*stripego.Product
|
||||
it := product.List(params)
|
||||
for it.Next() {
|
||||
if p := it.Product(); p.Metadata["product_id"] == productID {
|
||||
found = append(found, p)
|
||||
}
|
||||
}
|
||||
if err := it.Err(); err != nil {
|
||||
return nil, fmt.Errorf("stripe list products: %w", err)
|
||||
}
|
||||
return oldest(a.Logger, entry, found, func(p *stripego.Product) (string, int64) { return p.ID, p.Created }), nil
|
||||
}
|
||||
```
|
||||
|
||||
- [x] 3.3 `findPrice` and `samePrice`:
|
||||
|
||||
```go
|
||||
// findPrice is the Stripe price an earlier send of a requeued entry made, or
|
||||
// nil: an active price of the mapped Stripe product created since
|
||||
// lookupSince that samePrice finds to be the entry's. Like findProduct, it
|
||||
// reads Stripe's list under the attempt's context.
|
||||
func (a *OutboxActivities) findPrice(ctx context.Context, entry OutboxEntry, stripeProductID string, payload CreateStripePricePayload) (*stripego.Price, error) {
|
||||
params := &stripego.PriceListParams{
|
||||
Product: stripego.String(stripeProductID),
|
||||
Active: stripego.Bool(true),
|
||||
CreatedRange: &stripego.RangeQueryParams{GreaterThanOrEqual: lookupSince(entry)},
|
||||
}
|
||||
params.Context = ctx
|
||||
var found []*stripego.Price
|
||||
it := price.List(params)
|
||||
for it.Next() {
|
||||
if p := it.Price(); samePrice(p, payload) {
|
||||
found = append(found, p)
|
||||
}
|
||||
}
|
||||
if err := it.Err(); err != nil {
|
||||
return nil, fmt.Errorf("stripe list prices: %w", err)
|
||||
}
|
||||
return oldest(a.Logger, entry, found, func(p *stripego.Price) (string, int64) { return p.ID, p.Created }), nil
|
||||
}
|
||||
|
||||
// samePrice reports whether p is the price payload's create makes: the
|
||||
// console's price id in its metadata, and the amount, currency and interval
|
||||
// that decide what a member is charged.
|
||||
func samePrice(p *stripego.Price, payload CreateStripePricePayload) bool {
|
||||
interval := ""
|
||||
if p.Recurring != nil {
|
||||
interval = string(p.Recurring.Interval)
|
||||
}
|
||||
return p.Metadata["price_id"] == payload.PriceID &&
|
||||
p.UnitAmount == payload.UnitAmount &&
|
||||
strings.EqualFold(string(p.Currency), payload.Currency) &&
|
||||
interval == payload.RecurringInterval
|
||||
}
|
||||
```
|
||||
|
||||
- [x] 3.4 `executeCreateStripeProduct` (`outbox.go:438-469`):
|
||||
|
||||
```go
|
||||
// executeCreateStripeProduct maps the entry's product to a Stripe product and
|
||||
// writes a product_mappings row. A requeued entry first looks for the
|
||||
// product an earlier send made (findProduct) and maps it without a create;
|
||||
// otherwise, and when it finds none, the product is created under the
|
||||
// entry's idempotency key and the attempt's context. A create Stripe answers
|
||||
// with its own server error stops the entry (stopOnSavedServerError).
|
||||
func (a *OutboxActivities) executeCreateStripeProduct(ctx context.Context, entry OutboxEntry) error {
|
||||
var payload CreateStripeProductPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return fmt.Errorf("parse create_stripe_product payload: %w", err)
|
||||
}
|
||||
|
||||
var prod *stripego.Product
|
||||
var err error
|
||||
if entry.Requeues > 0 {
|
||||
if prod, err = a.findProduct(ctx, entry, payload.ProductID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
found := prod != nil
|
||||
if !found {
|
||||
params := buildStripeProductParams(payload)
|
||||
params.Context = ctx
|
||||
params.SetIdempotencyKey(outboxIdempotencyKey(entry))
|
||||
if prod, err = product.New(params); err != nil {
|
||||
return stopOnSavedServerError("product", fmt.Errorf("stripe create product: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
q := internalstripe.New(a.DB)
|
||||
// Livemode from the product Stripe returned or listed (stripe-environment-stamp D1).
|
||||
_, err = q.UpsertProductMapping(ctx, internalstripe.UpsertProductMappingParams{
|
||||
ProductID: payload.ProductID,
|
||||
StripeProductID: sql.NullString{String: prod.ID, Valid: true},
|
||||
SyncStatus: "synced",
|
||||
Livemode: sql.NullBool{Bool: prod.Livemode, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("write product mapping: %w", err)
|
||||
}
|
||||
|
||||
a.Logger.Info("stripe product mapped",
|
||||
slog.String("stripe_product_id", prod.ID),
|
||||
slog.String("product_id", payload.ProductID),
|
||||
slog.Bool("found_by_lookup", found))
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
- [x] 3.5 `executeCreateStripePrice` (`outbox.go:480-533`): its comment adds "A requeued entry first looks for the price an earlier send made (findPrice) and maps it without a create; a create Stripe answers with its own server error stops the entry (stopOnSavedServerError)." The lookup of the product mapping and its retriable refusal (`:488-496`) are unchanged; what follows them reads:
|
||||
|
||||
```go
|
||||
var p *stripego.Price
|
||||
if entry.Requeues > 0 {
|
||||
if p, err = a.findPrice(ctx, entry, prodMapping.StripeProductID.String, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
found := p != nil
|
||||
if !found {
|
||||
params := &stripego.PriceParams{
|
||||
Product: stripego.String(prodMapping.StripeProductID.String),
|
||||
UnitAmount: stripego.Int64(payload.UnitAmount),
|
||||
Currency: stripego.String(payload.Currency),
|
||||
}
|
||||
if payload.RecurringInterval != "" {
|
||||
params.Recurring = &stripego.PriceRecurringParams{
|
||||
Interval: stripego.String(payload.RecurringInterval),
|
||||
}
|
||||
}
|
||||
params.AddMetadata("price_id", payload.PriceID)
|
||||
params.AddMetadata("product_id", payload.ProductID)
|
||||
params.Context = ctx
|
||||
params.SetIdempotencyKey(outboxIdempotencyKey(entry))
|
||||
if p, err = price.New(params); err != nil {
|
||||
return stopOnSavedServerError("price", fmt.Errorf("stripe create price: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Livemode from the price Stripe returned or listed (stripe-environment-stamp D1).
|
||||
_, err = q.UpsertPriceMapping(ctx, internalstripe.UpsertPriceMappingParams{
|
||||
PriceID: payload.PriceID,
|
||||
StripePriceID: sql.NullString{String: p.ID, Valid: true},
|
||||
SyncStatus: "synced",
|
||||
Livemode: sql.NullBool{Bool: p.Livemode, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("write price mapping: %w", err)
|
||||
}
|
||||
|
||||
a.Logger.Info("stripe price mapped",
|
||||
slog.String("stripe_price_id", p.ID),
|
||||
slog.String("price_id", payload.PriceID),
|
||||
slog.Bool("found_by_lookup", found))
|
||||
|
||||
return nil
|
||||
```
|
||||
|
||||
- [x] 3.6 `executeCreateStripeCustomer` (`outbox.go:374-385`): `params.Context = ctx` (needed for #312's correctness, design D6) follows `params.AddMetadata("billing_account_id", payload.BillingAccountID)`, and the create's error returns `stopOnSavedServerError("customer", fmt.Errorf("stripe create customer: %w", err))`. Nothing else of the customer path changes: no code enqueues it, and Retry matches product and price entries only (design D4).
|
||||
|
||||
## 4. The panel's copy (after approval)
|
||||
|
||||
- [x] 4.1 C2: `internal/server/product_readiness.go:509`, the line `stripeRow.Detail += ". Use Retry to run it again."` reads `stripeRow.Detail += "."`; the row ends at the stored reason and a full stop.
|
||||
- [x] 4.2 C3: `internal/embeds/templates/partials/operator_product_readiness.html:90`, the line `<small class="text-muted d-block mt-1">The last sync to Stripe failed; retry to run it again.</small>` under the Retry Stripe sync control, is deleted.
|
||||
- [x] 4.3 C4: `internal/embeds/templates/partials/operator_product_readiness.html:88`, the control's label `Retry Stripe sync`, reads `Find or create in Stripe`; and `internal/server/operator_product_readiness_render_test.go:230` reads `wantTextOf(t, doc, "#product-readiness button", "Find or create in Stripe")`.
|
||||
|
||||
## 5. The tests and their breaks
|
||||
|
||||
- [x] 5.1 `TestStopOnSavedServerError` (`internal/integrations/stripe/workflows/outbox_workflow_test.go`, package `workflows`), rows through a named function (`checkStop`). Each row wraps its error as the create does, `fmt.Errorf("stripe create product: %w", e)`, and passes it to `stopOnSavedServerError("product", ...)`.
|
||||
- `Stripe's own 500`: `&stripego.Error{Type: stripego.ErrorTypeAPI, HTTPStatusCode: 500, Msg: "server error"}`; stops.
|
||||
- `Stripe's own 503`: the same with 503; stops.
|
||||
- `a gateway's error page`: `errors.New("Couldn't deserialize JSON (response status: 502, body sample: '<html><head><title>502 Bad Gateway</title></head></html>'): invalid character '<' looking for beginning of value")`, the plain error stripe-go returns; unchanged.
|
||||
- `no answer`: `&url.Error{Op: "Post", URL: "https://api.stripe.com/v1/products", Err: context.DeadlineExceeded}`; unchanged.
|
||||
- `a refusal`: `&stripego.Error{Type: stripego.ErrorTypeInvalidRequest, HTTPStatusCode: 400, Msg: "refused"}`; unchanged.
|
||||
- `a conflict`: the same with 409; unchanged.
|
||||
|
||||
A row that stops wants a `*temporal.ApplicationError` (`errors.As`, the helper's own return) that is non-retryable, of type `StripeOutcomeUnknown`, whose `Message()` is `Stripe answered with a server error; whether it created the product is unknown`; failure text `%s: the create's error came back %v, want a non-retryable StripeOutcomeUnknown reading %q`. A row that is unchanged wants the very error it passed (`==`); failure text `%s: the create's error came back %v, want it unchanged`.
|
||||
|
||||
- [x] 5.2 `TestOutboxEntryWorkflowDeadLettersAnUnknownOutcome` (same file). `outboxDeadLetterRecorder` gains `lastMsg string`, which `mockOutboxDeadLetter` sets from its third argument; the existing tests do not read it. The mocked `ExecuteOutboxEntry` counts its calls and returns `stopOnSavedServerError("price", fmt.Errorf("stripe create price: %w", &stripego.Error{Type: stripego.ErrorTypeAPI, HTTPStatusCode: 500, Msg: "server error"}))`. The workflow fails (failure text `an unknown outcome must fail the workflow`, `t.Fatal`); the activity ran once (failure text `an unknown outcome was attempted %d times, want once`); the entry was dead-lettered once, as entry 7 (failure text `the entry was dead-lettered %d times, as %d; want once, as 7`); the dead letter's text contains `whether it created the price is unknown` (failure text `the dead letter reads %q, want the unknown outcome`).
|
||||
|
||||
- [x] 5.3 A new `internal/integrations/stripe/workflows/outbox_lookup_db_test.go`, package `workflows_test`, holds items 5.4 to 5.6 and their helpers:
|
||||
- `catalogStripe`, installed as the mock's `Handle`, answers as Stripe would. `POST /v1/products` and `POST /v1/prices` answer Stripe's own 500 when `serverError` is set and make nothing; otherwise they make an active object stamped `time.Now().Unix()` from the form (`metadata[product_id]`; for a price `product`, `unit_amount`, `currency`, `recurring[interval]`, `metadata[price_id]`, `metadata[product_id]`), with the id `prod_` or `price_` followed by the console id and a counter, append it to `products` or `prices` and to `made`, and answer it. `POST /v1/customers` always answers Stripe's own 500. `GET /v1/products` and `GET /v1/prices` answer `listErr` when it is set; otherwise a `*stripe.ProductList` or `*stripe.PriceList` of the held objects that pass the form's `active=true`, `created[gte]` and, for prices, `product`, newest first; a parameter the form does not carry filters nothing. Any other call falls through.
|
||||
- A form the code under test got wrong fails the test: the mock answers a list whose `created[gte]` is not a number, and a price create whose `unit_amount` is not a number, with an invalid-request error (400). `mappedID` answers "" for no mapping row (`sql.ErrNoRows`) or one not synced, and fails the test on any other read error.
|
||||
- `callName(c)`: "the list" for `GET /v1/products` and `GET /v1/prices`, "the create" for `POST /v1/products`, `/v1/prices` and `/v1/customers`, otherwise `stripetest.Line(c)`.
|
||||
- `requeuedEntry(t, db, action, payload, requeues)` inserts a `stripe` row with the payload's JSON and `requeues`, returns its id and `created_at`, and deletes it in cleanup.
|
||||
- Fixtures: `world.New(t, database)`; a product `w.Product(name)`; a price `w.Price(p, 1000)` (usd, monthly); for a price entry, a synced product mapping to `"prod_" + p.ID` through `w.MapProductIn(p, false)`; a one-time price entry takes `w.OneTimePrice(p, 1000)` and the payload's `RecurringInterval` "". Payloads are `stripewf.CreateStripeProductPayload{ProductID: p.ID, Name: p.Name}` and `stripewf.CreateStripePricePayload{PriceID: pr.ID, ProductID: p.ID, UnitAmount: 1000, Currency: "usd", RecurringInterval: "month"}`. Seeded objects are stamped relative to the row's `created_at`.
|
||||
|
||||
- [x] 5.4 `TestAKeyedCreateAnsweredWithAServerErrorStops`, rows `a product`, `a price` and `a customer` (payload `internal/integrations/stripe/store.EnsureStripeCustomerPayload` with new UUIDs and the names "Outcome Org" and "Outcome Account"), each never requeued, with `serverError` set, run under `context.Background()`. Checks, each with `t.Errorf`:
|
||||
- the error is a non-retryable `*temporal.ApplicationError` of type `StripeOutcomeUnknown`, read as `appErr, ok := err.(*temporal.ApplicationError) //nolint:errorlint // Temporal reads only a top-level ApplicationError as non-retryable`, then `NonRetryable()` and `Type()`, never through `errors.As`, which Temporal does not use; failure text `%s: the attempt returned %v, want a non-retryable StripeOutcomeUnknown`;
|
||||
- the calls are the create alone; failure text `%s: the attempt sent %v, want the create alone`;
|
||||
- the row reads `failed`, one attempt, and an error containing `whether it created the <object> is unknown`; failure text `%s: the row reads %s, %d, %q, want failed, 1 and the unknown outcome`;
|
||||
- for the product and the price, no synced mapping of the product or the price; failure text `%s: the attempt left the mapping %q, want none`.
|
||||
|
||||
- [x] 5.5 `TestARequeuedCreateLooksBeforeItCreates`, rows through `checkLookup`. Each row inserts its entry (`requeues` 1 unless named), seeds `catalogStripe`, runs `ExecuteOutboxEntry` under `context.Background()`, and checks with `t.Errorf`: the calls' names (failure text `%s: the entry sent %v, want %v`); the error is nil (failure text `%s: the entry returned %v, want nil`); the mapping's Stripe id (failure text `%s: the mapping holds %q, want %q`), the seeded id where it says "mapped", the created one (`made[0]`) where it says "created"; the row reads its `wantStatus`, `completed` unless a row says otherwise (failure text `%s: the entry reads %q, want %s`). "+1s" is one second after the row's `created_at`.
|
||||
- `a product Stripe made`: a product with the entry's id, active, +1s. The list; mapped.
|
||||
- `a product Stripe stamped before the row`: the same at −10 minutes. The list; mapped.
|
||||
- `a product made before the window`: the same at −2 hours. The list, the create; created.
|
||||
- `an archived product`: the same, inactive, +1s. The list, the create; created.
|
||||
- `another product in the window`: active, +1s, another product id (`uuid.NewString()`). The list, the create; created.
|
||||
- `two products Stripe made`: `late` at +5s and `early` at +1s, both the entry's. The list; `early` mapped, and the log, written to a buffer, names `late`'s id (failure text `%s: the log names %q, want the other object %q`).
|
||||
- `two products Stripe made in one second`: two products at +1s, both the entry's. The list; the first seeded, the lower id, mapped.
|
||||
- `nothing made`: no seed. The list, the create; created, and the create carried `member-console-outbox-<id>-<created_at in microseconds>-1` (failure text `%s: the create carried key %q, want %q`).
|
||||
- `a first send`: `requeues` 0, the seed of the first row. The create alone; created, under `member-console-outbox-<id>-<created_at in microseconds>` (the same failure text).
|
||||
- `a first price send`: `requeues` 0, the seed of `a price Stripe made`. The create alone; created, under `member-console-outbox-<id>-<created_at in microseconds>` (the same failure text).
|
||||
- `a price Stripe made`: a price of `"prod_" + p.ID`, active, +1s, with the entry's price id, 1000, usd, monthly. The list; mapped.
|
||||
- `a one-time price Stripe made`: a price with no interval, otherwise the seed of `a price Stripe made`, with the entry's payload `RecurringInterval` "". The list; mapped.
|
||||
- `a price the console stores in capitals`: the seed of `a price Stripe made` (currency `usd`), with the entry's payload `Currency` `"USD"`. The list; mapped.
|
||||
- `a price of another product`: the seed of `a price Stripe made` on `"prod_" + uuid.NewString()`. The list, the create; created.
|
||||
- `a price with another amount` (2500), `a price in another currency` (eur), `a price with another interval` (yearly), `an archived price`, `another price in the window` (another price id): otherwise as the row above. The list, the create; created.
|
||||
- `the list fails` (a product) and `the price list fails` (a price): `listErr` is Stripe's own 500, and `wantStatus` is `failed`. The list alone; a row that wants `failed` wants an error that is not a non-retryable application error (failure text `%s: the entry returned %v, want a retryable error`) and no synced mapping (failure text `%s: the mapping holds %q, want none`), in place of the nil and mapping checks. Every other row wants `completed`.
|
||||
|
||||
- [x] 5.6 `TestOutboxCreatesCarryTheAttemptsContext`: rows `a product` and `a price` (`requeues` 1, nothing seeded: the list, the create) and `a customer` (never requeued, answered with the 500: the create), each run under `context.WithTimeout(context.Background(), time.Hour)`. A row first checks the calls' names (failure text `%s: the entry sent %v, want %v`, `t.Fatalf`), then that each call's `Deadline` equals the context's (failure text `%s: %s carried deadline %v, want %v`, the row, the call's name, the deadline recorded, the context's).
|
||||
|
||||
- [x] 5.7 `TestARequeuedEntrySendsANewKey` (`outbox_key_test.go`), on `OutboxEntry{ID: 1, CreatedAt: time.Date(2026, 9, 27, 9, 0, 0, 123456000, time.UTC)}`: never requeued, the key is `member-console-outbox-1-1790499600123456` (failure text `a never-requeued entry's key is %q, want %q`); requeued once, `member-console-outbox-1-1790499600123456-1` (failure text `the first requeue's key is %q, want %q`); requeued twice, a key other than the first requeue's (failure text `two requeues share the key %q`); requeued once with `Attempts` 3, the first requeue's key (failure text `an attempt of a requeued run changed its key: %q, then %q`). Each of the last two facts has a break of its own (item 5.11).
|
||||
|
||||
- [x] 5.8 `TestRetryCountsTheRequeue` (`internal/server/operator_sync_stripe_db_test.go`; `%s` is the entry's `action_type`): `newSyncEnv(t)`, `e.product("Plan")`, a press (the enqueue), `e.deadLetter` of both actions, a press (the Retry); each entry's `requeues`, read in id order through `outboxOf`, is 1 (failure text `after the first Retry, %s has requeues %d, want %d`). A press while the entries are pending leaves 1 (failure text `after a press while the sync runs, %s has requeues %d, want %d`). A second dead-letter and a press give 2 (failure text `after the second Retry, %s has requeues %d, want %d`).
|
||||
|
||||
- [x] 5.9 With C2 (the full stop kept, the sentence removed) and C3 approved, `TestSyncProductToStripeAnswers/readiness_panel_names_a_dead-lettered_sync` is regenerated with `-update`: its third line ends at the reason and a full stop, `fixture refusal.`, without "Use Retry to run it again.", and its fourth line, the hint under the control, is gone. No other line of it changes; the golden prints no control label, so C4 changes no golden.
|
||||
|
||||
- [x] 5.10 Every other existing test keeps its result: `TestOutboxIdempotencyKeyOutlivesTheSequence` (a never-requeued key is unchanged); `TestClassifyStripeError` (unchanged; it pins that a reconcile's 500 stays retryable); the outbox workflow tests (the recorder's new field is not read); `TestExecuteOutboxEntryRecordsAFailedAttemptOnTheRow` and `TestExecuteOutboxEntryDoesNotRepeatACompletedEntry` (the re-read gains a column); `product_catalog_test.go` and `customer_mapping_test.go`, which send through the SDK's backend with no key and accept `failed`, an answer that is never Stripe's own 500; the other `operator_sync_stripe` goldens (their state prints no `requeues`, and the statement record names Retry's update `UPDATE core.outbox` as before); `operator_product_readiness_render_test.go`, apart from its line `:230`, which C4 changes (item 4.3), the failure's error it reads being the same; `outbox_reconcile_subscription_test.go`; `TestConsoleReaderColumns` (passes with the mark of item 2.1a).
|
||||
|
||||
- [x] 5.11 Breaks. Package `./internal/integrations/stripe/workflows` and file `outbox.go` unless named; a record's `run_new` is the test and, for a row, its subtest.
|
||||
- the stop never stopping (`old` `if !errors.As(err, &se) || se.HTTPStatusCode < 500 {`, `new` `if errors.As(err, &se) || true {`, which keeps `se` read so the package compiles): caught by `TestStopOnSavedServerError/Stripe's_own_500`, `want_new` `Stripe's own 500: the create's error came back`; by `TestAKeyedCreateAnsweredWithAServerErrorStops/a_product`, `want_new` `a product: the attempt returned`; and by `TestOutboxEntryWorkflowDeadLettersAnUnknownOutcome`, `want_new` `an unknown outcome was attempted`;
|
||||
- the stop taking a refusal (`old` `se.HTTPStatusCode < 500 {`, `new` `se.HTTPStatusCode < 400 {`): caught by `TestStopOnSavedServerError/a_refusal`, `want_new` `a refusal: the create's error came back`, and by `.../a_conflict`, `want_new` `a conflict: the create's error came back`;
|
||||
- the stop taking a plain error (`old` `if !errors.As(err, &se) || se.HTTPStatusCode < 500 {`, `new` `if errors.As(err, &se) && se.HTTPStatusCode < 500 {`): caught by `TestStopOnSavedServerError/a_gateway's_error_page`, `want_new` `a gateway's error page: the create's error came back`, and by `.../no_answer`, `want_new` `no answer: the create's error came back`;
|
||||
- the reason losing its sentence (`old` `fmt.Sprintf("Stripe answered with a server error; whether it created the %s is unknown", object),`, `new` `fmt.Sprintf("%s", object),`): caught by `TestStopOnSavedServerError/Stripe's_own_500`, `want_new` `Stripe's own 500: the create's error came back`; by `TestAKeyedCreateAnsweredWithAServerErrorStops/a_product`, `want_new` `a product: the row reads`; and by `TestOutboxEntryWorkflowDeadLettersAnUnknownOutcome`, `want_new` `the dead letter reads`;
|
||||
- each create's error bypassing the stop: the product's (`old` `return stopOnSavedServerError("product", fmt.Errorf("stripe create product: %w", err))`, `new` `return fmt.Errorf("stripe create product: %w", err)`), caught by `TestAKeyedCreateAnsweredWithAServerErrorStops/a_product`, `want_new` `a product: the attempt returned`; the price's (the same with `price`), `.../a_price`, `want_new` `a price: the attempt returned`; the customer's (the same with `customer`), `.../a_customer`, `want_new` `a customer: the attempt returned`;
|
||||
- the key without the requeue (`old` `key += fmt.Sprintf("-%d", entry.Requeues)`, `new` `key += ""`): caught by `TestARequeuedEntrySendsANewKey`, `want_new` `the first requeue's key is`, and by `TestARequeuedCreateLooksBeforeItCreates/nothing_made`, `want_new` `nothing made: the create carried key`;
|
||||
- the re-read leaving the count unread (`old` `&entry.CreatedAt, &entry.Requeues); err != nil {`, `new` `&entry.CreatedAt, new(int)); err != nil {`): caught by `TestARequeuedCreateLooksBeforeItCreates/a_product_Stripe_made`, `want_new` `a product Stripe made: the entry sent`;
|
||||
- the product's lookup skipped (`old` `if prod, err = a.findProduct(ctx, entry, payload.ProductID); err != nil {`, `new` `if prod, err = (*stripego.Product)(nil), error(nil); err != nil {`): caught by `.../a_product_Stripe_made`, `want_new` `a product Stripe made: the entry sent`;
|
||||
- the price's lookup skipped (`old` `if p, err = a.findPrice(ctx, entry, prodMapping.StripeProductID.String, payload); err != nil {`, `new` `if p, err = (*stripego.Price)(nil), error(nil); err != nil {`): caught by `.../a_price_Stripe_made`, `want_new` `a price Stripe made: the entry sent`;
|
||||
- a first send looking (`old` `if entry.Requeues > 0 {\n\t\tif prod, err = a.findProduct(`, `new` `if true {\n\t\tif prod, err = a.findProduct(`): caught by `.../a_first_send`, `want_new` `a first send: the entry sent`;
|
||||
- a never-requeued entry's key changed (`old` `if entry.Requeues > 0 {\n\t\tkey += fmt.Sprintf("-%d", entry.Requeues)`, `new` `if entry.Requeues >= 0 {\n\t\tkey += fmt.Sprintf("-%d", entry.Requeues)`): caught by `TestARequeuedEntrySendsANewKey`, `want_new` `a never-requeued entry's key is`, and by `TestARequeuedCreateLooksBeforeItCreates/a_first_send`, `want_new` `a first send: the create carried key`;
|
||||
- Stripe's own 503 not stopping (`old` `se.HTTPStatusCode < 500 {`, `new` `se.HTTPStatusCode != 500 {`): caught by `TestStopOnSavedServerError/Stripe's_own_503`, `want_new` `Stripe's own 503: the create's error came back`;
|
||||
- a first price send looking (`old` `if entry.Requeues > 0 {\n\t\tif p, err = a.findPrice(`, `new` `if true {\n\t\tif p, err = a.findPrice(`): caught by `.../a_first_price_send`, `want_new` `a first price send: the entry sent`;
|
||||
- the attempt's error wrapped on its way to Temporal (`old` `a.recordFailedOutboxAttempt(ctx, entry.ID, currentAttempt(ctx), execErr)\n\t\treturn execErr`, `new` the same with `return fmt.Errorf("%w", execErr)`): caught by `TestAKeyedCreateAnsweredWithAServerErrorStops/a_product`, `want_new` `a product: the attempt returned`;
|
||||
- the product's metadata unread (`old` `p.Metadata["product_id"] == productID`, `new` `true`): caught by `.../another_product_in_the_window`, `want_new` `another product in the window: the entry sent`;
|
||||
- the price's id unread (`old` `return p.Metadata["price_id"] == payload.PriceID &&`, `new` `return true &&`): caught by `.../another_price_in_the_window`, `want_new` `another price in the window: the entry sent`;
|
||||
- the amount, the currency and the interval unread, one record each (`old` `p.UnitAmount == payload.UnitAmount &&`, `strings.EqualFold(string(p.Currency), payload.Currency) &&`, `interval == payload.RecurringInterval`; `new` `true &&`, `true &&`, `interval == interval`, which keeps `interval` read so the package compiles): caught by `.../a_price_with_another_amount`, `.../a_price_in_another_currency` and `.../a_price_with_another_interval`, `want_new` `a price with another amount: the entry sent`, `a price in another currency: the entry sent` and `a price with another interval: the entry sent`;
|
||||
- the currency compared case-sensitively (`old` `strings.EqualFold(string(p.Currency), payload.Currency) &&`, `new` `string(p.Currency) == payload.Currency &&`): caught by `.../a_price_the_console_stores_in_capitals`, `want_new` `a price the console stores in capitals: the entry sent`;
|
||||
- the price list unfiltered by product (`old` `Product: stripego.String(stripeProductID),`, `new` `Product: nil,`): caught by `.../a_price_of_another_product`, `want_new` `a price of another product: the entry sent`;
|
||||
- an attempt of a requeued run changing its key (`old` `key += fmt.Sprintf("-%d", entry.Requeues)`, `new` `key += fmt.Sprintf("-%d", entry.Requeues+entry.Attempts)`): caught by `TestARequeuedEntrySendsANewKey`, `want_new` `an attempt of a requeued run changed its key`;
|
||||
- two requeues sharing a key (the same `old`, `new` `key += fmt.Sprintf("-%d", min(entry.Requeues, 1))`): caught by `TestARequeuedEntrySendsANewKey`, `want_new` `two requeues share the key`;
|
||||
- archived products listed (`old` `params := &stripego.ProductListParams{\n\t\tActive: stripego.Bool(true),`, `new` `params := &stripego.ProductListParams{\n\t\tActive: nil,`): caught by `.../an_archived_product`, `want_new` `an archived product: the entry sent`;
|
||||
- archived prices listed (`old` `Product: stripego.String(stripeProductID),\n\t\tActive: stripego.Bool(true),`, `new` `Product: stripego.String(stripeProductID),\n\t\tActive: nil,`): caught by `.../an_archived_price`, `want_new` `an archived price: the entry sent`;
|
||||
- no window (`old` `return entry.CreatedAt.Add(-outboxLookupSkew).Unix()`, `new` `return 0`): caught by `.../a_product_made_before_the_window`, `want_new` `a product made before the window: the entry sent`;
|
||||
- no skew (`old` `const outboxLookupSkew = time.Hour`, `new` `const outboxLookupSkew = 0`): caught by `.../a_product_Stripe_stamped_before_the_row`, `want_new` `a product Stripe stamped before the row: the entry sent`;
|
||||
- the newest mapped (`old` `return cmp.Or(cmp.Compare(xa, ya), strings.Compare(xi, yi))`, `new` `return cmp.Or(cmp.Compare(ya, xa), strings.Compare(xi, yi))`): caught by `.../two_products_Stripe_made`, `want_new` `two products Stripe made: the mapping holds`;
|
||||
- a failed product list taken for an empty one (`old` `return nil, fmt.Errorf("stripe list products: %w", err)`, `new` `return nil, nil`): caught by `.../the_list_fails`, `want_new` `the list fails: the entry sent`;
|
||||
- a failed price list taken for an empty one (`old` `return nil, fmt.Errorf("stripe list prices: %w", err)`, `new` `return nil, nil`): caught by `.../the_price_list_fails`, `want_new` `the price list fails: the entry sent`;
|
||||
- each call without the attempt's context: the product's create (`old` `params := buildStripeProductParams(payload)\n\t\tparams.Context = ctx`, `new` `params := buildStripeProductParams(payload)`), caught by `TestOutboxCreatesCarryTheAttemptsContext/a_product`, `want_new` `a product: the create carried deadline`; the product's list (`old` `params.Context = ctx\n\tvar found []*stripego.Product`, `new` `var found []*stripego.Product`), `want_new` `a product: the list carried deadline`; the price's create (`old` `params.AddMetadata("product_id", payload.ProductID)\n\t\tparams.Context = ctx`, `new` `params.AddMetadata("product_id", payload.ProductID)`), `.../a_price`, `want_new` `a price: the create carried deadline`; the price's list (`old` `params.Context = ctx\n\tvar found []*stripego.Price`, `new` `var found []*stripego.Price`), `want_new` `a price: the list carried deadline`; the customer's create (`old` `params.AddMetadata("billing_account_id", payload.BillingAccountID)\n\tparams.Context = ctx`, `new` `params.AddMetadata("billing_account_id", payload.BillingAccountID)`), `.../a_customer`, `want_new` `a customer: the create carried deadline`;
|
||||
- the one-time price's interval read as monthly (`old` `\tinterval := ""`, `new` `\tinterval := "month"`): caught by `.../a_one-time_price_Stripe_made`, `want_new` `a one-time price Stripe made: the entry sent`;
|
||||
- the tie between two objects of one second broken by the later id (`old` `strings.Compare(xi, yi))`, `new` `strings.Compare(yi, xi))`): caught by `.../two_products_Stripe_made_in_one_second`, `want_new` `two products Stripe made in one second: the mapping holds`;
|
||||
- the other matches not logged at warning (`old` `logger.Warn("stripe outbox: an earlier send`, `new` `logger.Debug("stripe outbox: an earlier send`): caught by `.../two_products_Stripe_made`, `want_new` `two products Stripe made: the log names`;
|
||||
- Retry not counting (`internal/integrations/stripe/catalogsync/catalogsync.go`, `old` `SET status = 'pending', attempts = 0, requeues = requeues + 1, next_attempt_at = NOW(),`, `new` `SET status = 'pending', attempts = 0, next_attempt_at = NOW(),`, `pkg` `./internal/server`): caught by `TestRetryCountsTheRequeue`, `want_new` `after the first Retry, create_stripe_product has requeues 0`;
|
||||
- C2 restored (`internal/server/product_readiness.go`, `old` `stripeRow.Detail += "."`, `new` `stripeRow.Detail += ". Use Retry to run it again."`, `pkg` `./internal/server`): caught by `TestSyncProductToStripeAnswers/readiness_panel_names_a_dead-lettered_sync`, `want_new` `readiness_panel_names_a_dead-lettered_sync.golden differs from what the code produced`;
|
||||
- C3 reverted (`internal/embeds/templates/partials/operator_product_readiness.html`, `old` ` Find or create in Stripe\n </button>`, `new` the same followed by `\n <small class="text-muted d-block mt-1">The last sync to Stripe failed; retry to run it again.</small>`, `pkg` `./internal/server`): caught by the same row, `want_new` the same;
|
||||
- C4 reverted (`internal/embeds/templates/partials/operator_product_readiness.html`, `old` ` Find or create in Stripe\n </button>`, `new` ` Retry Stripe sync\n </button>`, `pkg` `./internal/server`): caught by `TestProductEditRendersPurchasabilityPanel/sync-failed_renders_the_red_failed_badge_and_a_Retry_action`, `want_new` `no element matching "#product-readiness button" reads "Find or create in Stripe"`.
|
||||
|
||||
Named without a break, with the reason: the migration's default, because a break of it fails every database test at the migration, before any test's claim; the comments of items 1.2, 2.2 and 2.5.
|
||||
|
||||
## 6. The documents
|
||||
|
||||
- [x] 6.1 `docs/stripe.md`, "How it works" (`:203`), after "until an operator retries the entry and the retry succeeds.": "A create that Stripe answers with its own error object and a status of 500 or above is marked `dead_letter` after that one attempt, because Stripe answers every later request with the same idempotency key from the saved error, and a server error does not say whether Stripe made the object; the entry's reason says so. A gateway's error page and a request that gets no answer are retried as before. Retry sends each dead-lettered entry back with a new idempotency key. Before its create, a retried entry lists the active products, or the active prices of its product, that Stripe created since an hour before the entry was written, and maps the oldest whose metadata carries the console's `product_id` or `price_id`, a price also with the entry's amount, currency and interval; it creates the object only when none does. The list uses Stripe's list API, which shows what Stripe has written, and not its search, which lags; it needs the Products and Prices read permissions the environment check already uses."
|
||||
- [x] 6.2 `docs/models/payments-billing.md:39`, the Outbox drainer's row, reads: "The recurring worker that executes outbox rows (create Stripe product / price / customer), retrying with backoff until a cap parks the row as `dead_letter` for operator-driven retry; a create Stripe answers with its own server error is parked at once, and an operator's retry looks for the object by the console's id in its metadata before it creates one under a new idempotency key".
|
||||
- [x] 6.3 No other document changes: `docs/database-locks.md` describes no lock this change moves, `docs/testing.md` no helper it adds (the tests answer through `Handle`), and `design/` is not edited.
|
||||
|
||||
## 7. Landing
|
||||
|
||||
- [x] 7.1 With the test stack up, `make test` passes (it runs `make lint` first), and `openspec validate look-up-an-unknown-create-before-sending-it-again --strict` passes.
|
||||
- [x] 7.2 The break list of item 5.11 reads CAUGHT in the commit message, with the breaks named without a record and why.
|
||||
- [x] 7.3 The deploy changes no data and needs no step of its own. Before it, a read-only count shows what the deploy will meet: `SELECT action_type, status, count(*) FROM core.outbox WHERE provider = 'stripe' AND action_type IN ('create_stripe_product', 'create_stripe_price', 'create_stripe_customer') AND status IN ('pending', 'processing', 'failed', 'dead_letter') GROUP BY 1, 2;`. After it, an entry still retrying a create that Stripe answers with its own server error is set aside at its next attempt; for each product whose readiness panel then shows Sync failed, or that the count found dead-lettered, an operator presses Find or create in Stripe on the product's page. A deployment that uses the `console_reader` role re-runs `scripts/db-reader-role.sql` as the schema owner, so the role can read `core.outbox.requeues`, which holds no personal data.
|
||||
- [x] 7.4 After the push, post on #312 under `CONTRIBUTING.md`'s rules for forge text (item 1: it reads for someone who has never opened the repository, explains the console's terms once, and names no decision number, change name, date as narrative, deployment or count), and close it. The quoted strings follow the approved Copy table. Draft:
|
||||
|
||||
> The outbox is the console's queue of Stripe actions: a row written in the same database transaction as the change that needs it, executed in the background, retried for up to a day, and then set aside as failed (dead-lettered) for an operator. An idempotency key is a value that tells Stripe two requests are one. A product or price create that Stripe answers with its own server error (Stripe's error object with a status of 500 or above) is now set aside after that one attempt, with the reason "Stripe answered with a server error; whether it created the price is unknown", because Stripe answers every later request with the same idempotency key from the saved error, and a server error does not say whether the object was made. The product's readiness panel shows the failed sync at once instead of "syncing" for a day. A gateway's error page, which carries no Stripe error object, and a request that gets no answer are still retried. Retry now counts each time it sends an entry back, and the entry's create then carries a new idempotency key. Before that create, the entry lists the products, or the prices of its product, that Stripe made since an hour before the entry was written, through Stripe's list, which shows what Stripe has written, rather than its search, which lags. It maps the oldest one whose metadata carries the console's product or price id, a price also with the same amount, currency and interval, and creates the object only when none does. An entry never sent back keeps the key it always had. Each create also ends with its attempt's two-minute deadline. After a failed sync, the readiness panel's line ends at the reason, without "Use Retry to run it again."; the second line under the control, which repeated it, is gone; and the control, which read "Retry Stripe sync", now reads "Find or create in Stripe".
|
||||
>
|
||||
> Commit: …
|
||||
|
||||
- [ ] 7.5 The two open questions of design.md are put to the maintainer as proposed issues, each in the bug form as written there; none is opened without the maintainer's go. A comment on #149, which needs no go, is posted under `CONTRIBUTING.md`'s rules for forge text (item 1, as in 7.4): "The product page's readiness panel shows the same stored text. Its Payment processing row reads \"The last Stripe sync failed: \" followed by the dead-lettered entry's stored error (`internal/server/product_readiness.go`, `commerceRows`), so the chain, its `retryable: false` included, reaches the product page beside the Find or create in Stripe control as well as the Delivery queue." A comment on #313, posted without a go and under the same rules: "The Stripe public-details read (`internal/integrations/stripe/workflows/public_details.go`, `ReadPublicDetails`) has the same shape: its attempt lasts one minute, its Stripe reads carry no context, and a read that outlives the attempt keeps calling Stripe beside the next attempt."
|
||||
- [ ] 7.6 The archive waits for the maintainer's go; the deltas carry the spec changes, and no Purpose needs an edit a delta cannot make.
|
||||
Reference in New Issue
Block a user