Record Stripe environment on mappings and verify ids under the current

key
This commit is contained in:
2026-09-20 01:51:45 -05:00
parent 9114271cc6
commit b7a0e15574
112 changed files with 8755 additions and 301 deletions
+62
View File
@@ -18,10 +18,12 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
"git.coopcloud.tech/wiki-cafe/member-console/internal/instance"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations"
stripeintegration "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe"
stripemod "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/logging"
"git.coopcloud.tech/wiki-cafe/member-console/internal/migrate"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
@@ -127,6 +129,11 @@ var startCmd = &cobra.Command{
logger.Error("invalid configuration", slog.Any("error", err))
os.Exit(1)
}
// The key's fingerprint, derived here beside its mode: a digest,
// never the key. The environment check records which key it ran
// under, and this is what the next boot compares against that
// record to learn the key moved (stripe-environment-stamp D3).
stripeKeyFingerprint := stripeintegration.FingerprintForKey(viper.GetString("stripe-api-key"))
// Database Setup
dbDSN := viper.GetString("db-dsn")
@@ -319,6 +326,21 @@ var startCmd = &cobra.Command{
logger.Info("started entitlement recompute poller workflow")
}
// The Stripe environment check (stripe-environment-stamp D3).
// A Stripe id resolves in the environment that made it and in
// no other, so a key that moved leaves every mapping pointing
// at an object this deployment can no longer read. Boot
// compares the key's fingerprint with the one the last check
// ran under; when they differ, or no check has ever run, every
// product and price id is unverified again and the read-back
// is started. Started, not awaited, like the poller above: a
// deployment whose Stripe is unreachable still comes up, and a
// start that fails is logged and left to the provider page's
// control.
if stripeMode != "" {
startStripeEnvironmentCheck(ctx, temporalClient, workerCfg.TaskQueue, database, stripeKeyFingerprint, logger)
}
// Set up the billing scheduled-change sweep — the backstop firing
// path for due subscription scheduled changes. Period-end
// cancellations also fire via the Stripe deletion webhook, so a
@@ -400,6 +422,7 @@ var startCmd = &cobra.Command{
StripeAPIKey: viper.GetString("stripe-api-key"),
StripeDashboardURL: stripeDashboardURL,
StripeMode: stripeMode,
StripeKeyFingerprint: stripeKeyFingerprint,
BaseURL: viper.GetString("base-url"),
AskFallbackURL: viper.GetString("domains-ask-fallback-url"),
DomainsPolicy: domainsPolicy,
@@ -417,6 +440,45 @@ var startCmd = &cobra.Command{
},
}
// startStripeEnvironmentCheck runs boot's half of the environment check
// (stripe-environment-stamp D3): read the record of the key the last check
// ran under, and when it names another key, or names none, clear every
// product and price id's verification and start the read-back.
//
// Nothing here waits for Stripe. The clears are two statements against the
// console's own tables, and the workflow is started and abandoned, so a
// deployment whose Stripe or Temporal is unreachable boots at the same
// speed it does today. A failed clear is logged and the check still
// starts: the read-back is what settles each row, and a stale
// verification timestamp on a row the check is about to rewrite is worth
// less than the run.
func startStripeEnvironmentCheck(ctx context.Context, c client.Client, taskQueue string, database *sql.DB, fingerprint string, logger *slog.Logger) {
raw, found, err := instance.NewStore(database).GetJSON(ctx, instance.StripeEnvironmentCheck)
if err != nil {
logger.Error("could not read the stripe environment check record", slog.Any("error", err))
return
}
if !stripemod.EnvironmentCheckNeeded(raw, found, fingerprint) {
return
}
q := stripemod.New(database)
if err := q.ClearProductMappingVerifiedAt(ctx); err != nil {
logger.Error("could not clear the product mappings' verification", slog.Any("error", err))
}
if err := q.ClearPriceMappingVerifiedAt(ctx); err != nil {
logger.Error("could not clear the price mappings' verification", slog.Any("error", err))
}
if _, err := stripewf.StartEnvironmentCheck(ctx, c, taskQueue, stripewf.EnvironmentCheckTriggerBoot); err != nil {
logger.Error("failed to start the stripe environment check workflow", slog.Any("error", err))
return
}
logger.Info("started the stripe environment check workflow",
slog.String("trigger", stripewf.EnvironmentCheckTriggerBoot),
slog.String("key_fingerprint", stripemod.FingerprintPrefix(fingerprint)))
}
func init() {
// Register flags with Cobra
// DO NOT SET DEFAULT VALUES HERE. Use viper.SetDefault() instead. https://github.com/spf13/viper/issues/671
+50 -2
View File
@@ -41,6 +41,54 @@ test from live but tells no sandbox apart from another sandbox or from
legacy test mode. Only reading a stored id back under the current key
shows that the object belongs to a different environment.
## Moving between environments
A deployment moves environments by changing its key: from a sandbox to
the live account when it goes into service, or back to a sandbox to
rehearse. Swap the API key and the webhook signing secret together and
restart; the two belong to one environment, and a secret left behind
keeps verifying the old environment's events.
Every synced object the console holds records the environment that made
it (`livemode` on each `stripe.*_mappings` row, written from the object
Stripe returned; rows from before the column exists hold nothing and
read as unverified). After the restart:
- The console compares a fingerprint of the key (a SHA-256 digest; the
key itself is never stored) with the one the last check ran under and,
when they differ, starts a background check that
reads every synced product and price back under the new key. The
Stripe integration page's **Environment check** section shows it
running, then its result (`Last checked ..., 34 verified, 2 stale.`),
and offers **Check now** to run it again. Nothing waits on the
check: boot finishes as before.
- A product whose mapping records the other environment shows it on its
Purchasability panel (`Synced in test, key is live`) and is not
purchasable until it is created again. The panel's sync control reads
**Create in live** (or **Create in test**) in that case and creates the
product and its price in the current environment; the old ids are
replaced.
- A mapping the check could not find under the new key (a move between
two sandboxes, which Stripe reports identically) is marked `stale` and
reads `Not found in live mode`; it behaves like a mapping from the
other environment.
- A member checkout, a subscription reconcile, a plan switch or a
scheduled change against such a mapping is refused before Stripe is
called and logged; nothing is sent under a key that cannot reach the
object.
- Webhook events from the other environment are stored as `refused`,
never processed, and counted on the Stripe integration page (`3 events
arrived in test mode under a live key.`). Unverified mappings block
nothing.
- The billing views show the rows recorded in the current environment.
When rows from the other environment exist, one line above the view
names how many are not shown and switches the view to all of them,
each marked with its environment.
Customers are not read back by the check: a customer id left behind by a
move between two sandboxes surfaces when that member's next checkout
fails, and the console then creates the customer again.
## Webhook endpoint
Register a webhook in the Stripe Dashboard pointing to:
@@ -67,7 +115,7 @@ Subscribe to these event types:
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`). `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.
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.
@@ -81,7 +129,7 @@ A member can only buy a product once **every** purchasability precondition is me
4. **Active price** — add a price on the product's Prices view.
5. **Stripe-mapped price** — the active price is mapped to a live Stripe price.
The last step is the one to do **explicitly**: on the Purchasability panel, click **Sync to Stripe**. That enqueues the product and price sync; the precondition shows **Sync pending** until the outbox worker drains, then flips to **Met** and the verdict becomes **Purchasable**. Adding a price does **not** auto-sync — click Sync to Stripe whenever a product or its active price is not yet mapped.
The last step is the one to do **explicitly**: on the Purchasability panel, click **Sync to Stripe**. That enqueues the product and price sync; the precondition shows **Sync pending** until the outbox worker drains, then flips to **Met** and the verdict becomes **Purchasable**. Adding a price does **not** auto-sync — click Sync to Stripe whenever a product or its active price is not yet mapped. When the mapping records another environment, or the environment check marked it stale, the same control reads **Create in live** (or **Create in test**) and creates the product and price again in the current one ("Moving between environments" above).
If Stripe is **not configured** for the deployment (no API key), the panel says so on the Stripe-mapped precondition instead of a dead "missing" marker — pricing simply cannot be Stripe-mapped, though grant-based access still works without Stripe.
+42 -1
View File
@@ -40,6 +40,36 @@ func (q *Queries) AssignNextInvoiceNumber(ctx context.Context, billingAccountID
return assigned, err
}
const countBillingAccountsPage = `-- name: CountBillingAccountsPage :one
SELECT count(*)::bigint AS total_count
FROM core.accounts a
WHERE ($1::text IS NULL
OR a.name ILIKE '%' || $1::text || '%'
OR a.org_id = ANY($2::uuid[]))
AND ($3::uuid[] IS NULL
OR a.billing_account_id <> ALL($3::uuid[]))
`
type CountBillingAccountsPageParams struct {
Q sql.NullString `json:"q"`
OrgIds []string `json:"org_ids"`
ExcludeIds []string `json:"exclude_ids"`
}
// The same predicates as ListBillingAccountsPage, without paging: the caller runs it twice,
// once with sqlc.narg(exclude_ids) and once without, and the difference is
// how many rows the environment filter holds back from the view the
// operator is looking at. The billing views' absence line reports that
// number under the search and the facet in force
// (stripe-environment-stamp D7), which the page query's count(*) OVER()
// cannot give, because it counts the rows that were shown.
func (q *Queries) CountBillingAccountsPage(ctx context.Context, arg CountBillingAccountsPageParams) (int64, error) {
row := q.db.QueryRowContext(ctx, countBillingAccountsPage, arg.Q, pq.Array(arg.OrgIds), pq.Array(arg.ExcludeIds))
var total_count int64
err := row.Scan(&total_count)
return total_count, err
}
const createBillingAccount = `-- name: CreateBillingAccount :one
INSERT INTO core.accounts (org_id, name, status, metadata)
VALUES ($1, $2, $3, $4)
@@ -219,13 +249,16 @@ FROM core.accounts a
WHERE ($1::text IS NULL
OR a.name ILIKE '%' || $1::text || '%'
OR a.org_id = ANY($2::uuid[]))
AND ($3::uuid[] IS NULL
OR a.billing_account_id <> ALL($3::uuid[]))
ORDER BY a.created_at DESC
LIMIT $4::int OFFSET $3::int
LIMIT $5::int OFFSET $4::int
`
type ListBillingAccountsPageParams struct {
Q sql.NullString `json:"q"`
OrgIds []string `json:"org_ids"`
ExcludeIds []string `json:"exclude_ids"`
PageOffset int32 `json:"page_offset"`
PageLimit int32 `json:"page_limit"`
}
@@ -256,11 +289,19 @@ type ListBillingAccountsPageRow struct {
// (grep 'core\.' internal/billing/queries/ turns up none), so
// operator_billing.go's matchingOrgIDs resolves matching org IDs in Go and
// passes them here, mirroring the grants lane's product-name pattern.
// sqlc.narg(exclude_ids): NULL excludes nothing; set, the rows whose id is
// in the array are left out of the page AND out of count(*) OVER(), so the
// pager's total counts only what renders. The operator billing views hand
// it the ids whose stripe mapping records the environment the API key is
// not in (stripe-environment-stamp D7), resolved in the stripe store the
// same way org_ids and invoice_ids are resolved in Go, because no query
// here crosses that module boundary.
// count(*) OVER() carries the true total for the filtered set (design D2).
func (q *Queries) ListBillingAccountsPage(ctx context.Context, arg ListBillingAccountsPageParams) ([]ListBillingAccountsPageRow, error) {
rows, err := q.db.QueryContext(ctx, listBillingAccountsPage,
arg.Q,
pq.Array(arg.OrgIds),
pq.Array(arg.ExcludeIds),
arg.PageOffset,
arg.PageLimit,
)
+57 -1
View File
@@ -17,6 +17,51 @@ import (
"github.com/lib/pq"
)
const countInvoicesPage = `-- name: CountInvoicesPage :one
SELECT count(*)::bigint AS total_count
FROM core.invoices i
JOIN core.accounts ba ON i.billing_account_id = ba.billing_account_id
WHERE ($1::text IS NULL
OR ba.name ILIKE '%' || $1::text || '%'
OR i.invoice_number ILIKE '%' || $1::text || '%'
OR ba.org_id = ANY($2::uuid[])
OR i.invoice_id = ANY($3::uuid[]))
AND ($4::text IS NULL
OR ($4::text = 'overdue'
AND i.status = 'open' AND i.due_date IS NOT NULL AND i.due_date < now())
OR ($4::text <> 'overdue' AND i.status = $4::text))
AND ($5::uuid[] IS NULL
OR i.invoice_id <> ALL($5::uuid[]))
`
type CountInvoicesPageParams struct {
Q sql.NullString `json:"q"`
OrgIds []string `json:"org_ids"`
InvoiceIds []string `json:"invoice_ids"`
Status sql.NullString `json:"status"`
ExcludeIds []string `json:"exclude_ids"`
}
// The same predicates as ListInvoicesPage, without paging: the caller runs it twice,
// once with sqlc.narg(exclude_ids) and once without, and the difference is
// how many rows the environment filter holds back from the view the
// operator is looking at. The billing views' absence line reports that
// number under the search and the facet in force
// (stripe-environment-stamp D7), which the page query's count(*) OVER()
// cannot give, because it counts the rows that were shown.
func (q *Queries) CountInvoicesPage(ctx context.Context, arg CountInvoicesPageParams) (int64, error) {
row := q.db.QueryRowContext(ctx, countInvoicesPage,
arg.Q,
pq.Array(arg.OrgIds),
pq.Array(arg.InvoiceIds),
arg.Status,
pq.Array(arg.ExcludeIds),
)
var total_count int64
err := row.Scan(&total_count)
return total_count, err
}
const countOpenInvoices = `-- name: CountOpenInvoices :one
SELECT COUNT(*) FROM core.invoices
WHERE status = 'open'
@@ -259,8 +304,10 @@ WHERE ($1::text IS NULL
OR ($4::text = 'overdue'
AND i.status = 'open' AND i.due_date IS NOT NULL AND i.due_date < now())
OR ($4::text <> 'overdue' AND i.status = $4::text))
AND ($5::uuid[] IS NULL
OR i.invoice_id <> ALL($5::uuid[]))
ORDER BY i.created_at DESC
LIMIT $6::int OFFSET $5::int
LIMIT $7::int OFFSET $6::int
`
type ListInvoicesPageParams struct {
@@ -268,6 +315,7 @@ type ListInvoicesPageParams struct {
OrgIds []string `json:"org_ids"`
InvoiceIds []string `json:"invoice_ids"`
Status sql.NullString `json:"status"`
ExcludeIds []string `json:"exclude_ids"`
PageOffset int32 `json:"page_offset"`
PageLimit int32 `json:"page_limit"`
}
@@ -321,6 +369,13 @@ type ListInvoicesPageRow struct {
// Overdue filter's total is computed in SQL, never after LIMIT (the same
// predicate operator_billing.go's invoiceIsOverdue applies in Go for
// per-row presentation; a test pins their agreement).
// sqlc.narg(exclude_ids): NULL excludes nothing; set, the rows whose id is
// in the array are left out of the page AND out of count(*) OVER(), so the
// pager's total counts only what renders. The operator billing views hand
// it the ids whose stripe mapping records the environment the API key is
// not in (stripe-environment-stamp D7), resolved in the stripe store the
// same way org_ids and invoice_ids are resolved in Go, because no query
// here crosses that module boundary.
// count(*) OVER() carries the true total for the filtered set (design D2).
func (q *Queries) ListInvoicesPage(ctx context.Context, arg ListInvoicesPageParams) ([]ListInvoicesPageRow, error) {
rows, err := q.db.QueryContext(ctx, listInvoicesPage,
@@ -328,6 +383,7 @@ func (q *Queries) ListInvoicesPage(ctx context.Context, arg ListInvoicesPagePara
pq.Array(arg.OrgIds),
pq.Array(arg.InvoiceIds),
arg.Status,
pq.Array(arg.ExcludeIds),
arg.PageOffset,
arg.PageLimit,
)
+44 -1
View File
@@ -17,6 +17,38 @@ import (
"github.com/lib/pq"
)
const countPaymentsPage = `-- name: CountPaymentsPage :one
SELECT count(*)::bigint AS total_count
FROM core.payments p
JOIN core.accounts ba ON p.billing_account_id = ba.billing_account_id
JOIN core.invoices inv ON p.invoice_id = inv.invoice_id
WHERE ($1::text IS NULL
OR ba.name ILIKE '%' || $1::text || '%'
OR ba.org_id = ANY($2::uuid[]))
AND ($3::uuid[] IS NULL
OR p.payment_id <> ALL($3::uuid[]))
`
type CountPaymentsPageParams struct {
Q sql.NullString `json:"q"`
OrgIds []string `json:"org_ids"`
ExcludeIds []string `json:"exclude_ids"`
}
// The same predicates as ListPaymentsPage, without paging: the caller runs it twice,
// once with sqlc.narg(exclude_ids) and once without, and the difference is
// how many rows the environment filter holds back from the view the
// operator is looking at. The billing views' absence line reports that
// number under the search and the facet in force
// (stripe-environment-stamp D7), which the page query's count(*) OVER()
// cannot give, because it counts the rows that were shown.
func (q *Queries) CountPaymentsPage(ctx context.Context, arg CountPaymentsPageParams) (int64, error) {
row := q.db.QueryRowContext(ctx, countPaymentsPage, arg.Q, pq.Array(arg.OrgIds), pq.Array(arg.ExcludeIds))
var total_count int64
err := row.Scan(&total_count)
return total_count, err
}
const createPayment = `-- name: CreatePayment :one
INSERT INTO core.payments (
invoice_id, billing_account_id, payment_method_id,
@@ -151,13 +183,16 @@ JOIN core.invoices inv ON p.invoice_id = inv.invoice_id
WHERE ($1::text IS NULL
OR ba.name ILIKE '%' || $1::text || '%'
OR ba.org_id = ANY($2::uuid[]))
AND ($3::uuid[] IS NULL
OR p.payment_id <> ALL($3::uuid[]))
ORDER BY p.created_at DESC
LIMIT $4::int OFFSET $3::int
LIMIT $5::int OFFSET $4::int
`
type ListPaymentsPageParams struct {
Q sql.NullString `json:"q"`
OrgIds []string `json:"org_ids"`
ExcludeIds []string `json:"exclude_ids"`
PageOffset int32 `json:"page_offset"`
PageLimit int32 `json:"page_limit"`
}
@@ -198,11 +233,19 @@ type ListPaymentsPageRow struct {
// Joins to core.invoices (not left -- invoice_id is NOT NULL on
// core.payments) to carry invoice_number: invoice-numbers D3 puts the
// number in the title of this view's "View invoice" cross-reference.
// sqlc.narg(exclude_ids): NULL excludes nothing; set, the rows whose id is
// in the array are left out of the page AND out of count(*) OVER(), so the
// pager's total counts only what renders. The operator billing views hand
// it the ids whose stripe mapping records the environment the API key is
// not in (stripe-environment-stamp D7), resolved in the stripe store the
// same way org_ids and invoice_ids are resolved in Go, because no query
// here crosses that module boundary.
// count(*) OVER() carries the true total for the filtered set (design D2).
func (q *Queries) ListPaymentsPage(ctx context.Context, arg ListPaymentsPageParams) ([]ListPaymentsPageRow, error) {
rows, err := q.db.QueryContext(ctx, listPaymentsPage,
arg.Q,
pq.Array(arg.OrgIds),
pq.Array(arg.ExcludeIds),
arg.PageOffset,
arg.PageLimit,
)
+60
View File
@@ -41,6 +41,22 @@ type Querier interface {
// MarkDefaultPrice (clear before set, so the partial unique index never sees
// two defaults for one product).
ClearDefaultPrice(ctx context.Context, productID string) error
// The same predicates as ListBillingAccountsPage, without paging: the caller runs it twice,
// once with sqlc.narg(exclude_ids) and once without, and the difference is
// how many rows the environment filter holds back from the view the
// operator is looking at. The billing views' absence line reports that
// number under the search and the facet in force
// (stripe-environment-stamp D7), which the page query's count(*) OVER()
// cannot give, because it counts the rows that were shown.
CountBillingAccountsPage(ctx context.Context, arg CountBillingAccountsPageParams) (int64, error)
// The same predicates as ListInvoicesPage, without paging: the caller runs it twice,
// once with sqlc.narg(exclude_ids) and once without, and the difference is
// how many rows the environment filter holds back from the view the
// operator is looking at. The billing views' absence line reports that
// number under the search and the facet in force
// (stripe-environment-stamp D7), which the page query's count(*) OVER()
// cannot give, because it counts the rows that were shown.
CountInvoicesPage(ctx context.Context, arg CountInvoicesPageParams) (int64, error)
// Both halves of the operator overview's Monthly recurring caption in one
// read: 'active' and 'trialing' are the two statuses that mean the
// subscription is presently owed service (the pair operator_billing.go
@@ -53,6 +69,14 @@ type Querier interface {
// off. A count, not a balance sum — currency is per-row, and a cross-currency
// sum would be a number the tile could not stand behind.
CountOpenInvoices(ctx context.Context) (int64, error)
// The same predicates as ListPaymentsPage, without paging: the caller runs it twice,
// once with sqlc.narg(exclude_ids) and once without, and the difference is
// how many rows the environment filter holds back from the view the
// operator is looking at. The billing views' absence line reports that
// number under the search and the facet in force
// (stripe-environment-stamp D7), which the page query's count(*) OVER()
// cannot give, because it counts the rows that were shown.
CountPaymentsPage(ctx context.Context, arg CountPaymentsPageParams) (int64, error)
// Operator overview tile: the size of the live catalog. Counts only
// lifecycle_status='published' — drafts are work in progress and retired
// products are history, so neither belongs in a "what we sell today" number.
@@ -61,6 +85,14 @@ type Querier interface {
// are a tier on some plan ladder, vs. off-ladder. A product counts once even
// when it is shared across more than one ladder (DISTINCT).
CountPublishedTierProducts(ctx context.Context) (int64, error)
// The same predicates as ListSubscriptionsPage, without paging: the caller runs it twice,
// once with sqlc.narg(exclude_ids) and once without, and the difference is
// how many rows the environment filter holds back from the view the
// operator is looking at. The billing views' absence line reports that
// number under the search and the facet in force
// (stripe-environment-stamp D7), which the page query's count(*) OVER()
// cannot give, because it counts the rows that were shown.
CountSubscriptionsPage(ctx context.Context, arg CountSubscriptionsPageParams) (int64, error)
CreateBillingAccount(ctx context.Context, arg CreateBillingAccountParams) (Account, error)
// invoice_number is the platform-assigned reference number (invoice-numbers
// D1-D3): the caller assigns it via AssignNextInvoiceNumber (billing_accounts.sql)
@@ -191,6 +223,13 @@ type Querier interface {
// (grep 'core\.' internal/billing/queries/ turns up none), so
// operator_billing.go's matchingOrgIDs resolves matching org IDs in Go and
// passes them here, mirroring the grants lane's product-name pattern.
// sqlc.narg(exclude_ids): NULL excludes nothing; set, the rows whose id is
// in the array are left out of the page AND out of count(*) OVER(), so the
// pager's total counts only what renders. The operator billing views hand
// it the ids whose stripe mapping records the environment the API key is
// not in (stripe-environment-stamp D7), resolved in the stripe store the
// same way org_ids and invoice_ids are resolved in Go, because no query
// here crosses that module boundary.
// count(*) OVER() carries the true total for the filtered set (design D2).
ListBillingAccountsPage(ctx context.Context, arg ListBillingAccountsPageParams) ([]ListBillingAccountsPageRow, error)
// Sweeper backstop: due, not-yet-fired rows. Concurrency safety comes from the
@@ -225,6 +264,13 @@ type Querier interface {
// Overdue filter's total is computed in SQL, never after LIMIT (the same
// predicate operator_billing.go's invoiceIsOverdue applies in Go for
// per-row presentation; a test pins their agreement).
// sqlc.narg(exclude_ids): NULL excludes nothing; set, the rows whose id is
// in the array are left out of the page AND out of count(*) OVER(), so the
// pager's total counts only what renders. The operator billing views hand
// it the ids whose stripe mapping records the environment the API key is
// not in (stripe-environment-stamp D7), resolved in the stripe store the
// same way org_ids and invoice_ids are resolved in Go, because no query
// here crosses that module boundary.
// count(*) OVER() carries the true total for the filtered set (design D2).
ListInvoicesPage(ctx context.Context, arg ListInvoicesPageParams) ([]ListInvoicesPageRow, error)
ListLaddersByProduct(ctx context.Context, productID string) ([]ListLaddersByProductRow, error)
@@ -244,6 +290,13 @@ type Querier interface {
// Joins to core.invoices (not left -- invoice_id is NOT NULL on
// core.payments) to carry invoice_number: invoice-numbers D3 puts the
// number in the title of this view's "View invoice" cross-reference.
// sqlc.narg(exclude_ids): NULL excludes nothing; set, the rows whose id is
// in the array are left out of the page AND out of count(*) OVER(), so the
// pager's total counts only what renders. The operator billing views hand
// it the ids whose stripe mapping records the environment the API key is
// not in (stripe-environment-stamp D7), resolved in the stripe store the
// same way org_ids and invoice_ids are resolved in Go, because no query
// here crosses that module boundary.
// count(*) OVER() carries the true total for the filtered set (design D2).
ListPaymentsPage(ctx context.Context, arg ListPaymentsPageParams) ([]ListPaymentsPageRow, error)
ListPlanLadders(ctx context.Context) ([]PlanLadder, error)
@@ -315,6 +368,13 @@ type Querier interface {
// (00010_schema_hardening.sql: chk_subscriptions_status_valid --
// incomplete, incomplete_expired, trialing, active, past_due, canceled,
// unpaid, paused).
// sqlc.narg(exclude_ids): NULL excludes nothing; set, the rows whose id is
// in the array are left out of the page AND out of count(*) OVER(), so the
// pager's total counts only what renders. The operator billing views hand
// it the ids whose stripe mapping records the environment the API key is
// not in (stripe-environment-stamp D7), resolved in the stripe store the
// same way org_ids and invoice_ids are resolved in Go, because no query
// here crosses that module boundary.
// count(*) OVER() carries the true total for the filtered set (design D2).
ListSubscriptionsPage(ctx context.Context, arg ListSubscriptionsPageParams) ([]ListSubscriptionsPageRow, error)
ListTiersByLadder(ctx context.Context, planLadderID string) ([]PlanLadderTier, error)
@@ -34,6 +34,13 @@ RETURNING *;
-- (grep 'core\.' internal/billing/queries/ turns up none), so
-- operator_billing.go's matchingOrgIDs resolves matching org IDs in Go and
-- passes them here, mirroring the grants lane's product-name pattern.
-- sqlc.narg(exclude_ids): NULL excludes nothing; set, the rows whose id is
-- in the array are left out of the page AND out of count(*) OVER(), so the
-- pager's total counts only what renders. The operator billing views hand
-- it the ids whose stripe mapping records the environment the API key is
-- not in (stripe-environment-stamp D7), resolved in the stripe store the
-- same way org_ids and invoice_ids are resolved in Go, because no query
-- here crosses that module boundary.
-- count(*) OVER() carries the true total for the filtered set (design D2).
SELECT a.billing_account_id, a.org_id, a.name, a.status, a.metadata, a.created_at, a.updated_at,
count(*) OVER() AS total_count
@@ -41,9 +48,27 @@ FROM core.accounts a
WHERE (sqlc.narg(q)::text IS NULL
OR a.name ILIKE '%' || sqlc.narg(q)::text || '%'
OR a.org_id = ANY(sqlc.narg(org_ids)::uuid[]))
AND (sqlc.narg(exclude_ids)::uuid[] IS NULL
OR a.billing_account_id <> ALL(sqlc.narg(exclude_ids)::uuid[]))
ORDER BY a.created_at DESC
LIMIT sqlc.arg(page_limit)::int OFFSET sqlc.arg(page_offset)::int;
-- name: CountBillingAccountsPage :one
-- The same predicates as ListBillingAccountsPage, without paging: the caller runs it twice,
-- once with sqlc.narg(exclude_ids) and once without, and the difference is
-- how many rows the environment filter holds back from the view the
-- operator is looking at. The billing views' absence line reports that
-- number under the search and the facet in force
-- (stripe-environment-stamp D7), which the page query's count(*) OVER()
-- cannot give, because it counts the rows that were shown.
SELECT count(*)::bigint AS total_count
FROM core.accounts a
WHERE (sqlc.narg(q)::text IS NULL
OR a.name ILIKE '%' || sqlc.narg(q)::text || '%'
OR a.org_id = ANY(sqlc.narg(org_ids)::uuid[]))
AND (sqlc.narg(exclude_ids)::uuid[] IS NULL
OR a.billing_account_id <> ALL(sqlc.narg(exclude_ids)::uuid[]));
-- name: GetLatestSubscriptionByBillingAccountID :one
-- Most recent subscription for a billing account (any status). Used by the
-- per-org composite's billing-summary section.
+32
View File
@@ -72,6 +72,13 @@ RETURNING *;
-- Overdue filter's total is computed in SQL, never after LIMIT (the same
-- predicate operator_billing.go's invoiceIsOverdue applies in Go for
-- per-row presentation; a test pins their agreement).
-- sqlc.narg(exclude_ids): NULL excludes nothing; set, the rows whose id is
-- in the array are left out of the page AND out of count(*) OVER(), so the
-- pager's total counts only what renders. The operator billing views hand
-- it the ids whose stripe mapping records the environment the API key is
-- not in (stripe-environment-stamp D7), resolved in the stripe store the
-- same way org_ids and invoice_ids are resolved in Go, because no query
-- here crosses that module boundary.
-- count(*) OVER() carries the true total for the filtered set (design D2).
SELECT
i.invoice_id,
@@ -103,9 +110,34 @@ WHERE (sqlc.narg(q)::text IS NULL
OR (sqlc.narg(status)::text = 'overdue'
AND i.status = 'open' AND i.due_date IS NOT NULL AND i.due_date < now())
OR (sqlc.narg(status)::text <> 'overdue' AND i.status = sqlc.narg(status)::text))
AND (sqlc.narg(exclude_ids)::uuid[] IS NULL
OR i.invoice_id <> ALL(sqlc.narg(exclude_ids)::uuid[]))
ORDER BY i.created_at DESC
LIMIT sqlc.arg(page_limit)::int OFFSET sqlc.arg(page_offset)::int;
-- name: CountInvoicesPage :one
-- The same predicates as ListInvoicesPage, without paging: the caller runs it twice,
-- once with sqlc.narg(exclude_ids) and once without, and the difference is
-- how many rows the environment filter holds back from the view the
-- operator is looking at. The billing views' absence line reports that
-- number under the search and the facet in force
-- (stripe-environment-stamp D7), which the page query's count(*) OVER()
-- cannot give, because it counts the rows that were shown.
SELECT count(*)::bigint AS total_count
FROM core.invoices i
JOIN core.accounts ba ON i.billing_account_id = ba.billing_account_id
WHERE (sqlc.narg(q)::text IS NULL
OR ba.name ILIKE '%' || sqlc.narg(q)::text || '%'
OR i.invoice_number ILIKE '%' || sqlc.narg(q)::text || '%'
OR ba.org_id = ANY(sqlc.narg(org_ids)::uuid[])
OR i.invoice_id = ANY(sqlc.narg(invoice_ids)::uuid[]))
AND (sqlc.narg(status)::text IS NULL
OR (sqlc.narg(status)::text = 'overdue'
AND i.status = 'open' AND i.due_date IS NOT NULL AND i.due_date < now())
OR (sqlc.narg(status)::text <> 'overdue' AND i.status = sqlc.narg(status)::text))
AND (sqlc.narg(exclude_ids)::uuid[] IS NULL
OR i.invoice_id <> ALL(sqlc.narg(exclude_ids)::uuid[]));
-- name: ListRecentInvoices :many
-- Recent invoices for the operator landing activity timeline. Joins to
-- core.accounts to resolve the org_id (same schema, safe for sqlc).
+27
View File
@@ -32,6 +32,13 @@ ORDER BY created_at DESC;
-- Joins to core.invoices (not left -- invoice_id is NOT NULL on
-- core.payments) to carry invoice_number: invoice-numbers D3 puts the
-- number in the title of this view's "View invoice" cross-reference.
-- sqlc.narg(exclude_ids): NULL excludes nothing; set, the rows whose id is
-- in the array are left out of the page AND out of count(*) OVER(), so the
-- pager's total counts only what renders. The operator billing views hand
-- it the ids whose stripe mapping records the environment the API key is
-- not in (stripe-environment-stamp D7), resolved in the stripe store the
-- same way org_ids and invoice_ids are resolved in Go, because no query
-- here crosses that module boundary.
-- count(*) OVER() carries the true total for the filtered set (design D2).
SELECT
p.payment_id,
@@ -58,9 +65,29 @@ JOIN core.invoices inv ON p.invoice_id = inv.invoice_id
WHERE (sqlc.narg(q)::text IS NULL
OR ba.name ILIKE '%' || sqlc.narg(q)::text || '%'
OR ba.org_id = ANY(sqlc.narg(org_ids)::uuid[]))
AND (sqlc.narg(exclude_ids)::uuid[] IS NULL
OR p.payment_id <> ALL(sqlc.narg(exclude_ids)::uuid[]))
ORDER BY p.created_at DESC
LIMIT sqlc.arg(page_limit)::int OFFSET sqlc.arg(page_offset)::int;
-- name: CountPaymentsPage :one
-- The same predicates as ListPaymentsPage, without paging: the caller runs it twice,
-- once with sqlc.narg(exclude_ids) and once without, and the difference is
-- how many rows the environment filter holds back from the view the
-- operator is looking at. The billing views' absence line reports that
-- number under the search and the facet in force
-- (stripe-environment-stamp D7), which the page query's count(*) OVER()
-- cannot give, because it counts the rows that were shown.
SELECT count(*)::bigint AS total_count
FROM core.payments p
JOIN core.accounts ba ON p.billing_account_id = ba.billing_account_id
JOIN core.invoices inv ON p.invoice_id = inv.invoice_id
WHERE (sqlc.narg(q)::text IS NULL
OR ba.name ILIKE '%' || sqlc.narg(q)::text || '%'
OR ba.org_id = ANY(sqlc.narg(org_ids)::uuid[]))
AND (sqlc.narg(exclude_ids)::uuid[] IS NULL
OR p.payment_id <> ALL(sqlc.narg(exclude_ids)::uuid[]));
-- name: ListRecentPayments :many
-- Recent payments for the operator landing activity timeline. Joins to
-- core.accounts to resolve the org_id (same schema, safe for sqlc).
@@ -38,6 +38,13 @@ RETURNING *;
-- (00010_schema_hardening.sql: chk_subscriptions_status_valid --
-- incomplete, incomplete_expired, trialing, active, past_due, canceled,
-- unpaid, paused).
-- sqlc.narg(exclude_ids): NULL excludes nothing; set, the rows whose id is
-- in the array are left out of the page AND out of count(*) OVER(), so the
-- pager's total counts only what renders. The operator billing views hand
-- it the ids whose stripe mapping records the environment the API key is
-- not in (stripe-environment-stamp D7), resolved in the stripe store the
-- same way org_ids and invoice_ids are resolved in Go, because no query
-- here crosses that module boundary.
-- count(*) OVER() carries the true total for the filtered set (design D2).
SELECT
s.subscription_id,
@@ -59,9 +66,29 @@ WHERE (sqlc.narg(q)::text IS NULL
OR ba.name ILIKE '%' || sqlc.narg(q)::text || '%'
OR ba.org_id = ANY(sqlc.narg(org_ids)::uuid[]))
AND (sqlc.narg(status)::text IS NULL OR s.status = sqlc.narg(status)::text)
AND (sqlc.narg(exclude_ids)::uuid[] IS NULL
OR s.subscription_id <> ALL(sqlc.narg(exclude_ids)::uuid[]))
ORDER BY s.created_at DESC
LIMIT sqlc.arg(page_limit)::int OFFSET sqlc.arg(page_offset)::int;
-- name: CountSubscriptionsPage :one
-- The same predicates as ListSubscriptionsPage, without paging: the caller runs it twice,
-- once with sqlc.narg(exclude_ids) and once without, and the difference is
-- how many rows the environment filter holds back from the view the
-- operator is looking at. The billing views' absence line reports that
-- number under the search and the facet in force
-- (stripe-environment-stamp D7), which the page query's count(*) OVER()
-- cannot give, because it counts the rows that were shown.
SELECT count(*)::bigint AS total_count
FROM core.subscriptions s
JOIN core.accounts ba ON s.billing_account_id = ba.billing_account_id
WHERE (sqlc.narg(q)::text IS NULL
OR ba.name ILIKE '%' || sqlc.narg(q)::text || '%'
OR ba.org_id = ANY(sqlc.narg(org_ids)::uuid[]))
AND (sqlc.narg(status)::text IS NULL OR s.status = sqlc.narg(status)::text)
AND (sqlc.narg(exclude_ids)::uuid[] IS NULL
OR s.subscription_id <> ALL(sqlc.narg(exclude_ids)::uuid[]));
-- name: CountLiveSubscriptionsByStatus :one
-- Both halves of the operator overview's Monthly recurring caption in one
-- read: 'active' and 'trialing' are the two statuses that mean the
+50 -1
View File
@@ -40,6 +40,44 @@ func (q *Queries) CountLiveSubscriptionsByStatus(ctx context.Context) (CountLive
return i, err
}
const countSubscriptionsPage = `-- name: CountSubscriptionsPage :one
SELECT count(*)::bigint AS total_count
FROM core.subscriptions s
JOIN core.accounts ba ON s.billing_account_id = ba.billing_account_id
WHERE ($1::text IS NULL
OR ba.name ILIKE '%' || $1::text || '%'
OR ba.org_id = ANY($2::uuid[]))
AND ($3::text IS NULL OR s.status = $3::text)
AND ($4::uuid[] IS NULL
OR s.subscription_id <> ALL($4::uuid[]))
`
type CountSubscriptionsPageParams struct {
Q sql.NullString `json:"q"`
OrgIds []string `json:"org_ids"`
Status sql.NullString `json:"status"`
ExcludeIds []string `json:"exclude_ids"`
}
// The same predicates as ListSubscriptionsPage, without paging: the caller runs it twice,
// once with sqlc.narg(exclude_ids) and once without, and the difference is
// how many rows the environment filter holds back from the view the
// operator is looking at. The billing views' absence line reports that
// number under the search and the facet in force
// (stripe-environment-stamp D7), which the page query's count(*) OVER()
// cannot give, because it counts the rows that were shown.
func (q *Queries) CountSubscriptionsPage(ctx context.Context, arg CountSubscriptionsPageParams) (int64, error) {
row := q.db.QueryRowContext(ctx, countSubscriptionsPage,
arg.Q,
pq.Array(arg.OrgIds),
arg.Status,
pq.Array(arg.ExcludeIds),
)
var total_count int64
err := row.Scan(&total_count)
return total_count, err
}
const createSubscription = `-- name: CreateSubscription :one
INSERT INTO core.subscriptions (billing_account_id, status, current_period_start, current_period_end)
VALUES ($1, $2, $3, $4)
@@ -169,14 +207,17 @@ WHERE ($1::text IS NULL
OR ba.name ILIKE '%' || $1::text || '%'
OR ba.org_id = ANY($2::uuid[]))
AND ($3::text IS NULL OR s.status = $3::text)
AND ($4::uuid[] IS NULL
OR s.subscription_id <> ALL($4::uuid[]))
ORDER BY s.created_at DESC
LIMIT $5::int OFFSET $4::int
LIMIT $6::int OFFSET $5::int
`
type ListSubscriptionsPageParams struct {
Q sql.NullString `json:"q"`
OrgIds []string `json:"org_ids"`
Status sql.NullString `json:"status"`
ExcludeIds []string `json:"exclude_ids"`
PageOffset int32 `json:"page_offset"`
PageLimit int32 `json:"page_limit"`
}
@@ -211,12 +252,20 @@ type ListSubscriptionsPageRow struct {
// (00010_schema_hardening.sql: chk_subscriptions_status_valid --
// incomplete, incomplete_expired, trialing, active, past_due, canceled,
// unpaid, paused).
// sqlc.narg(exclude_ids): NULL excludes nothing; set, the rows whose id is
// in the array are left out of the page AND out of count(*) OVER(), so the
// pager's total counts only what renders. The operator billing views hand
// it the ids whose stripe mapping records the environment the API key is
// not in (stripe-environment-stamp D7), resolved in the stripe store the
// same way org_ids and invoice_ids are resolved in Go, because no query
// here crosses that module boundary.
// count(*) OVER() carries the true total for the filtered set (design D2).
func (q *Queries) ListSubscriptionsPage(ctx context.Context, arg ListSubscriptionsPageParams) ([]ListSubscriptionsPageRow, error) {
rows, err := q.db.QueryContext(ctx, listSubscriptionsPage,
arg.Q,
pq.Array(arg.OrgIds),
arg.Status,
pq.Array(arg.ExcludeIds),
arg.PageOffset,
arg.PageLimit,
)
@@ -0,0 +1,16 @@
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- The environment the event came from, in the provider's own vocabulary:
-- Stripe writes `live` or `test`, from the event envelope's livemode.
-- core.webhook_events is provider-neutral, so this column holds each
-- provider's own word the way provider, provider_event_id, event_type and
-- provider_event_at already do, and carries no CHECK constraint for the
-- same reason event_type carries none: a second provider's vocabulary is
-- not the console's to enumerate. Rows from before this migration keep
-- NULL, which names no environment.
ALTER TABLE core.webhook_events ADD COLUMN provider_environment TEXT;
-- +goose Down
ALTER TABLE core.webhook_events DROP COLUMN provider_environment;
@@ -11,7 +11,9 @@
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> {{ .Error }}
</div>
{{ else if eq (len .Accounts) 0 }}
{{ else }}
{{ template "environmentNotice" .EnvNotice }}
{{ if eq (len .Accounts) 0 }}
{{ if .Nav.Filtered }}
{{ template "listControls" .Nav }}
{{ template "listNoMatch" .Nav }}
@@ -40,7 +42,7 @@
<a href="{{ routeURL "/operator/organizations/{orgID}" .OrgID }}">{{ .OrgName }}</a>
{{ else }}
<span class="text-muted">{{ .OrgID }}</span>
{{ end }}
{{ end }}{{ if .EnvState }}<span class="ms-1">{{ template "statusBadge" .EnvBadge }}</span>{{ end }}
</th>
<td>{{ .Name }}</td>
<td>{{ template "statusBadge" .StatusBadge }}</td>
@@ -65,3 +67,4 @@
</div>
{{ template "listPager" .Nav }}
{{ end }}
{{ end }}
@@ -24,6 +24,15 @@
</div>
</div>
{{/* Environment check: where the read-back of the mapped Stripe ids
stands under the key in force (stripe-environment-stamp D3). Only
rendered when Stripe is configured, because there is no key to read
anything back under otherwise. */}}
{{ if .Configured }}
{{ template "sectionHeader" .EnvironmentCheckHeader }}
{{ template "operator_integration_stripe_environment.html" .EnvironmentCheck }}
{{ end }}
{{/* Delivery queue: moved from the settings page (integration-settings
ADDED requirement "The Stripe provider page carries its status and
the delivery-queue report"); only the dead-letter bucket ever carries
@@ -89,21 +98,31 @@
<p class="small text-danger">
{{ .InboundEvents.DeadLetter }} event{{ if ne .InboundEvents.DeadLetter 1 }}s{{ end }} could not be processed after retries and need{{ if eq .InboundEvents.DeadLetter 1 }}s{{ end }} an operator.
</p>
{{ end }}
{{/* Events from the other Stripe environment: captured and not processed
(stripe-environment-stamp D6). Not alarm styling, because no operator
can move them; the line renders only when some arrived. */}}
{{ with .InboundEvents.RefusedLine }}
<p class="small text-body-secondary">{{ . }}</p>
{{ end }}
{{ if .InboundEventEntries }}
<div class="table-responsive">
{{/* list-scale exempt: detail page, the Stripe integration's own dead-lettered inbound events */}}
{{/* list-scale exempt: detail page, the Stripe integration's own dead-lettered and refused inbound events */}}
{{/* The second column reads Detail, not Error: the table holds two
kinds of row, and a refused row carries the reason it was kept
rather than a message from something that failed. */}}
<table class="table table-hover table-sm table-record">
<thead>
<tr>
<th>Event</th>
<th>Error</th>
<th>Detail</th>
<th>Attempts</th>
<th>Last attempt</th>
</tr>
</thead>
<tbody>
{{ range .InboundEventEntries }}
<tr class="table-danger">
<tr{{ if not .Refused }} class="table-danger"{{ end }}>
<th scope="row"><code class="text-nowrap">{{ .OperationType }}</code></th>
<td>{{ if .ErrorMessage }}{{ .ErrorMessage }}{{ else }}<span class="text-muted"></span>{{ end }}</td>
<td>{{ .Attempts }}</td>
@@ -114,7 +133,7 @@
</table>
</div>
{{ end }}
{{ else }}
{{ if .InboundEvents.Quiet }}
<p class="small text-body-secondary">
Inbound events are processing normally.
</p>
@@ -0,0 +1,18 @@
{{- /* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial */ -}}
{{- /* SPDX-FileCopyrightText: 2025-2026 Christian Galo */ -}}
{{/* operator_integration_stripe_environment.html
The Environment check section's body (stripe-environment-stamp D3):
one resting line and the control that runs the read-back again. Its
own file, and its own id, because the control's POST re-renders this
much and no more; the section header above it belongs to the page. */}}
<div id="stripe-environment-check" class="mb-3">
<p class="small text-body-secondary">{{ .Line }}</p>
<button type="button" class="btn btn-sm btn-outline-secondary"
hx-post="{{ routeURL "/partials/operator/integrations/stripe/environment-check" }}"
hx-target="#stripe-environment-check"
hx-swap="outerHTML"
hx-sync="this:replace">
Check now
</button>
</div>
@@ -13,7 +13,9 @@
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> {{ .Error }}
</div>
{{ else if eq (len .Invoices) 0 }}
{{ else }}
{{ template "environmentNotice" .EnvNotice }}
{{ if eq (len .Invoices) 0 }}
{{ if .Nav.Filtered }}
{{ template "listControls" .Nav }}
{{ template "listNoMatch" .Nav }}
@@ -45,7 +47,7 @@
<a href="{{ routeURL "/operator/billing/invoices/{invoiceID}" .InvoiceID }}">{{ .InvoiceNumber }}</a>
{{ else }}
<a href="{{ routeURL "/operator/billing/invoices/{invoiceID}" .InvoiceID }}" title="A reference number is assigned when the invoice is issued.">Unnumbered</a>
{{ end }}
{{ end }}{{ if .EnvState }}<span class="ms-1">{{ template "statusBadge" .EnvBadge }}</span>{{ end }}
</th>
<td>
{{ if .OrgName }}
@@ -68,3 +70,4 @@
</div>
{{ template "listPager" .Nav }}
{{ end }}
{{ end }}
@@ -29,6 +29,14 @@
empty-state-guidance owns): names the term, links
back to the bare list.
environmentNotice takes a server.EnvironmentNotice instead: the one
muted line above a billing view that names the rows its Stripe
environment filter is holding back and carries the switch between
the two states (stripe-environment-stamp D7). It renders nothing
when the filter hides nothing. It lives here rather than in each of
the four billing views because its markup and its switch are the
same line four times over.
When nav.Target is set (embedded lists), every control issues a
scoped htmx request (hx-get with hx-target/hx-select on the panel's
selector, outerHTML swap, URL pushed) so only that panel re-renders
@@ -100,3 +108,9 @@
<p class="small text-body-secondary mb-0"><a href="{{ .BasePath }}"{{ if .Target }} hx-get="{{ .BasePath }}" hx-target="{{ .Target }}" hx-select="{{ .Target }}"{{ if .SyncSelect }} hx-select-oob="{{ .SyncSelect }}"{{ end }} hx-swap="outerHTML" hx-push-url="true"{{ end }}>Clear search and filters</a></p>
</div>
{{ end }}
{{ define "environmentNotice" }}
{{ if .Show }}
<p class="small text-body-secondary mb-2">{{ .Text }} <a href="{{ .LinkHref }}"{{ if .Target }} hx-get="{{ .LinkHref }}" hx-target="{{ .Target }}" hx-select="{{ .Target }}"{{ if .SyncSelect }} hx-select-oob="{{ .SyncSelect }}"{{ end }} hx-swap="outerHTML" hx-push-url="true"{{ end }}>{{ .LinkLabel }}</a></p>
{{ end }}
{{ end }}
@@ -14,7 +14,9 @@
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> {{ .Error }}
</div>
{{ else if eq (len .Payments) 0 }}
{{ else }}
{{ template "environmentNotice" .EnvNotice }}
{{ if eq (len .Payments) 0 }}
{{ if .Nav.Filtered }}
{{ template "listControls" .Nav }}
{{ template "listNoMatch" .Nav }}
@@ -44,7 +46,7 @@
<a href="{{ routeURL "/operator/organizations/{orgID}" .OrgID }}">{{ .OrgName }}</a>
{{ else }}
<span class="text-muted">{{ .OrgID }}</span>
{{ end }}
{{ end }}{{ if .EnvState }}<span class="ms-1">{{ template "statusBadge" .EnvBadge }}</span>{{ end }}
</th>
<td>{{ if .InvoiceID }}<a href="{{ routeURL "/operator/billing/invoices/{invoiceID}" .InvoiceID }}">{{ if .InvoiceNumber }}{{ .InvoiceNumber }}{{ else }}Invoice{{ end }}</a>{{ else }}<span class="text-muted"></span>{{ end }}</td>
<td>{{ template "statusBadge" .StatusBadge }}</td>
@@ -59,3 +61,4 @@
</div>
{{ template "listPager" .Nav }}
{{ end }}
{{ end }}
@@ -65,9 +65,14 @@
<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">
Sync to Stripe
{{/* The label is "Sync to Stripe", or "Create in live" /
"Create in test" when the recorded id is one this key
cannot reach and the press creates the object again
(stripe-environment-stamp D5). No confirm modal: the modal
is the destructive idiom, and the Payment processing row
above already states why the old id is no good. */}}
{{ .Readiness.SyncLabel }}
</button>
<small class="text-muted d-block mt-1">Registers the product and its price with Stripe so members can buy this product.</small>
</div>
{{ else if .Readiness.CanRetryStripe }}
{{/* form-conventions "Button weight follows the button's role" (design
@@ -14,7 +14,9 @@
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> {{ .Error }}
</div>
{{ else if eq (len .Subscriptions) 0 }}
{{ else }}
{{ template "environmentNotice" .EnvNotice }}
{{ if eq (len .Subscriptions) 0 }}
{{ if .Nav.Filtered }}
{{ template "listControls" .Nav }}
{{ template "listNoMatch" .Nav }}
@@ -42,7 +44,7 @@
<a href="{{ routeURL "/operator/organizations/{orgID}" .OrgID }}">{{ .OrgName }}</a>
{{ else }}
<span class="text-muted">{{ .OrgID }}</span>
{{ end }}
{{ end }}{{ if .EnvState }}<span class="ms-1">{{ template "statusBadge" .EnvBadge }}</span>{{ end }}
</th>
<td>
{{ template "statusBadge" .StatusBadge }}
@@ -64,3 +66,4 @@
</div>
{{ template "listPager" .Nav }}
{{ end }}
{{ end }}
@@ -0,0 +1,223 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package fulfillment_test
// The three fulfillment paths' environment guard (stripe-environment-stamp
// D5, tasks 3.8, 3.9 and 3.10): reconcile skips a subscription the current
// key cannot reach, the plan switch refuses before the Stripe call, and the
// sweeper skips a due change without claiming its row, so a later run
// fires it once the ids are reachable again. An unverified mapping is a
// disagreement on none of them.
//
// stripetest.Install pins the API key at sk_test_mock, so the key mode is
// test throughout and a mapping stamped live is the unreachable one. Each
// case then sets the mock's Err, which turns any Stripe call the guard
// failed to prevent into a loud failure.
import (
"context"
"database/sql"
"errors"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/fulfillment"
"git.coopcloud.tech/wiki-cafe/member-console/internal/stripetest"
stripe "github.com/stripe/stripe-go/v81"
)
// stampSubscriptionMapping rewrites the stamp reconcile wrote, so a case
// can put a settled subscription into each of the states design D2 names.
func stampSubscriptionMapping(t *testing.T, database *sql.DB, stripeSubID string, livemode sql.NullBool, syncStatus string) {
t.Helper()
if _, err := database.ExecContext(context.Background(),
`UPDATE stripe.subscription_mappings SET livemode = $2, sync_status = $3 WHERE stripe_subscription_id = $1`,
stripeSubID, livemode, syncStatus); err != nil {
t.Fatalf("stamp subscription mapping: %v", err)
}
}
func stampPriceMapping(t *testing.T, database *sql.DB, priceID string, livemode sql.NullBool, syncStatus string) {
t.Helper()
if _, err := database.ExecContext(context.Background(),
`UPDATE stripe.price_mappings SET livemode = $2, sync_status = $3 WHERE price_id = $1`,
priceID, livemode, syncStatus); err != nil {
t.Fatalf("stamp price mapping: %v", err)
}
}
var (
recordedLive = sql.NullBool{Bool: true, Valid: true}
recordedTest = sql.NullBool{Bool: false, Valid: true}
unstamped = sql.NullBool{}
)
// TestReconcileSkipsUnreachableSubscription pins the
// stripe-subscription-creation scenario "Reconcile skips a subscription the
// key cannot reach", and its unverified control.
func TestReconcileSkipsUnreachableSubscription(t *testing.T) {
cases := []struct {
name string
livemode sql.NullBool
syncStatus string
wantSkip bool
}{
{"recorded in live under a test key", recordedLive, "synced", true},
{"stale though the stamp agrees", recordedTest, "stale", true},
{"unverified is not a disagreement", unstamped, "synced", false},
{"recorded in the key's own environment", recordedTest, "synced", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
database := testDB(t)
fx := seedFixture(t, database)
ctx := context.Background()
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
before := changeCount(t, database, core)
stampSubscriptionMapping(t, database, fx.stripeSubID, tc.livemode, tc.syncStatus)
// Any Stripe call from here is a guard that did not fire.
mock.Err = errors.New("stripetest: reconcile must not call stripe")
err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), fx.stripeSubID, "test:guard")
if tc.wantSkip {
if err != nil {
t.Fatalf("a skipped reconcile must change nothing and report nothing, got: %v", err)
}
if after := changeCount(t, database, core); after != before {
t.Errorf("subscription_changes = %d, want %d unchanged", after, before)
}
if got := mappingSyncStatus(t, database, fx.stripeSubID); got != tc.syncStatus {
t.Errorf("sync_status = %q, want %q untouched", got, tc.syncStatus)
}
return
}
if err == nil {
t.Fatal("a reachable mapping must reach Stripe, and this backend fails every call")
}
})
}
}
// TestSwitchPlanRefusesUnreachableMapping pins the
// stripe-subscription-management scenario "Switch onto a price the key
// cannot reach is refused": the refusal carries the unavailable-plan error
// the member surface maps to its own message, and nothing changes.
func TestSwitchPlanRefusesUnreachableMapping(t *testing.T) {
cases := []struct {
name string
stampPrice bool
livemode sql.NullBool
syncStatus string
wantRefuse bool
}{
{"target price recorded in live under a test key", true, recordedLive, "synced", true},
{"target price marked stale", true, recordedTest, "stale", true},
{"subscription recorded in live under a test key", false, recordedLive, "synced", true},
{"unverified target price is not a disagreement", true, unstamped, "synced", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
database := testDB(t)
fx := seedFixture(t, database)
ctx := context.Background()
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
prod2, corePrice2, stripePrice2 := secondTier(t, database, fx)
mock.Sub = fx.activeSub(&stripe.SubscriptionItem{ID: "si_plan_" + fx.sfx, Quantity: 1, Price: &stripe.Price{ID: stripePrice2}})
if tc.stampPrice {
stampPriceMapping(t, database, corePrice2, tc.livemode, tc.syncStatus)
} else {
stampSubscriptionMapping(t, database, fx.stripeSubID, tc.livemode, tc.syncStatus)
}
err := fulfillment.SwitchPlan(ctx, database, discardLogger(), fx.orgID, fx.ladderID, corePrice2, "member:switch")
if !tc.wantRefuse {
if err != nil {
t.Fatalf("an unverified mapping must switch as it does today, got: %v", err)
}
return
}
if !errors.Is(err, fulfillment.ErrPriceUnreachable) {
t.Fatalf("err = %v, want ErrPriceUnreachable", err)
}
if n := activeAttachCount(t, database, fx.ladderID, prod2); n != 0 {
t.Errorf("tier2 attach = %d, want 0: a refused switch changes nothing", n)
}
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 1 {
t.Errorf("tier1 attach = %d, want 1: a refused switch leaves the member where they were", n)
}
})
}
}
// TestSweepSkipsWithoutClaimingTheRow pins the
// stripe-subscription-management scenario "A change the key cannot fire is
// skipped, not consumed": the due row stays scheduled, so a later run fires
// it once the ids are reachable.
func TestSweepSkipsWithoutClaimingTheRow(t *testing.T) {
database := testDB(t)
fx := seedFixture(t, database)
ctx := context.Background()
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
if _, err := billing.New(database).CreateScheduledChange(ctx, billing.CreateScheduledChangeParams{
SubscriptionID: core,
ChangeType: "cancellation",
EffectiveAt: time.Now().Add(-time.Hour),
EffectiveTrigger: "period_end",
}); err != nil {
t.Fatalf("seed due change: %v", err)
}
stampSubscriptionMapping(t, database, fx.stripeSubID, recordedLive, "synced")
mock.Err = errors.New("stripetest: the sweeper must not call stripe")
if _, err := fulfillment.SweepDueScheduledChanges(ctx, database, discardLogger()); err != nil {
t.Fatalf("sweep: %v", err)
}
if n := scalarInt(t, database,
`SELECT count(*) FROM core.subscription_scheduled_changes WHERE subscription_id=$1 AND status='scheduled'`, core); n != 1 {
t.Errorf("scheduled rows = %d, want 1: a skipped change must not be claimed", n)
}
if n := scalarInt(t, database,
`SELECT count(*) FROM core.subscription_scheduled_changes WHERE subscription_id=$1 AND status='applied'`, core); n != 0 {
t.Errorf("applied rows = %d, want 0", n)
}
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 1 {
t.Errorf("attach = %d, want 1: a skipped cancellation ends nothing", n)
}
// Reachable again, the same due row fires on the next run.
stampSubscriptionMapping(t, database, fx.stripeSubID, recordedTest, "synced")
canceled := fx.activeSub(fx.planItem())
canceled.Status = stripe.SubscriptionStatusCanceled
mock.Err, mock.Sub = nil, canceled
if _, err := fulfillment.SweepDueScheduledChanges(ctx, database, discardLogger()); err != nil {
t.Fatalf("second sweep: %v", err)
}
if n := scalarInt(t, database,
`SELECT count(*) FROM core.subscription_scheduled_changes WHERE subscription_id=$1 AND status='applied'`, core); n != 1 {
t.Errorf("applied rows = %d, want 1 once the mapping is reachable again", n)
}
}
+58
View File
@@ -46,6 +46,14 @@ var ErrSameTier = errors.New("target tier is the current tier")
// cannot move a subscription onto a retired rate via preview or switch.
var ErrPriceInactive = errors.New("target price is not active")
// ErrPriceUnreachable is returned when a switch would send a Stripe id the
// current API key cannot reach: the target price mapping, the subscription
// mapping or the item mapping records the environment the key is not in,
// or the environment check marked it stale (stripe-environment-stamp D5).
// It renders as the unavailable-plan message, the same one a retired price
// gets, since to the member the two are one fact.
var ErrPriceUnreachable = errors.New("switch: a stripe id on this switch is not reachable under the current api key")
// ErrCommitmentFee is returned when a mid-term move is gated by an
// early_termination_policy='fee' commitment, whose fee economics are not yet
// implemented (see status/issues.md). The move is refused rather than performed
@@ -70,6 +78,27 @@ type ladderSubscription struct {
currentProductID string
stripeItemID string // Stripe si_ id of the item on this ladder
sub billing.Subscription
// coreItemID and the two mapping stamps are what a caller needs to ask
// whether the ids above can be reached under the current API key
// (stripe-environment-stamp D5). Resolving already reads both mapping
// rows, so carrying their state costs no query.
coreItemID string
subLivemode sql.NullBool
subSyncStatus string
itemLivemode sql.NullBool
itemSyncStatus string
}
// logUnreachableMapping writes the one-line skip design D5 specifies, in
// the shape all three fulfillment paths share: the message names the
// mapping and the two environments, and the fields carry the row it is
// about and the environments as recorded.
func logUnreachableMapping(logger *slog.Logger, msg, rowID, stripeID string, livemode sql.NullBool, syncStatus, keyMode string) {
logger.Warn(msg,
slog.String("row_id", rowID),
slog.String("stripe_id", stripeID),
slog.String("recorded_mode", internalstripe.RecordedMode(livemode, syncStatus)),
slog.String("key_mode", keyMode))
}
// SwitchPlan modifies the member's active subscription on ladderID to the target
@@ -103,6 +132,30 @@ func SwitchPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, orgID, lad
return fmt.Errorf("switch: resolve stripe price for target %s: %w", targetPriceID, err)
}
// Refuse before the Stripe call when any of the three ids the switch
// would send is out of reach under the current key: the target price,
// the subscription, or the item on it (stripe-environment-stamp D5).
// Nothing has changed at this point, so the refusal leaves the
// subscription exactly as it was.
keyMode := internalstripe.ModeForKey(stripe.Key)
for _, m := range []struct {
rowID, stripeID string
livemode sql.NullBool
syncStatus string
}{
{targetPriceID, priceMapping.StripePriceID.String, priceMapping.Livemode, priceMapping.SyncStatus},
{ls.coreSubID, ls.stripeSubID, ls.subLivemode, ls.subSyncStatus},
{ls.coreItemID, ls.stripeItemID, ls.itemLivemode, ls.itemSyncStatus},
} {
if internalstripe.MappingReachable(m.livemode, m.syncStatus, keyMode) {
continue
}
logUnreachableMapping(logger,
"plan_change: price mapping unreachable under the current key; skipping",
m.rowID, m.stripeID, m.livemode, m.syncStatus, keyMode)
return ErrPriceUnreachable
}
// Gate only a downgrade against a live commitment; upgrades pass through.
down, err := isDowngradeOnLadder(ctx, billingQ, ladderID, ls.currentProductID, targetPrice.ProductID)
if err != nil {
@@ -440,6 +493,11 @@ func resolveLadderSubscription(ctx context.Context, db *sql.DB, orgID, ladderID
currentProductID: currentProductID,
stripeItemID: itemMapping.StripeSubscriptionItemID.String,
sub: sub,
coreItemID: coreItemID,
subLivemode: subMapping.Livemode,
subSyncStatus: subMapping.SyncStatus,
itemLivemode: itemMapping.Livemode,
itemSyncStatus: itemMapping.SyncStatus,
}
return ls, nil
}
+26
View File
@@ -46,6 +46,22 @@ func ReconcileSubscription(ctx context.Context, db *sql.DB, logger *slog.Logger,
logger = slog.Default()
}
// A subscription whose recorded environment disagrees with the key, or
// whose row the environment check marked stale, is skipped before the
// fetch: the id does not exist under this key, so the call would fail
// and the retry would fail the same way (stripe-environment-stamp D5).
// A row that carries no stamp, and a deployment whose key mode is
// unknown, reconcile as they do today.
keyMode := internalstripe.ModeForKey(stripe.Key)
if mapping, mErr := internalstripe.New(db).GetSubscriptionMappingByStripeID(ctx,
sql.NullString{String: stripeSubscriptionID, Valid: true}); mErr == nil &&
!internalstripe.MappingReachable(mapping.Livemode, mapping.SyncStatus, keyMode) {
logUnreachableMapping(logger,
"reconcile: subscription mapping unreachable under the current key; skipping",
mapping.SubscriptionID, stripeSubscriptionID, mapping.Livemode, mapping.SyncStatus, keyMode)
return nil
}
// 1.3 Authoritative read from the Stripe API at the SDK-pinned version.
// The price is expanded so each item carries its product linkage.
getParams := &stripe.SubscriptionParams{}
@@ -236,10 +252,15 @@ func upsertSubscription(
if cerr != nil {
return "", fmt.Errorf("reconcile: create subscription: %w", cerr)
}
// The console never creates a subscription, so the environment stamp
// rides this read-back: sub is what subscription.Get just returned,
// and its Livemode is the environment the id lives in
// (stripe-environment-stamp D4).
if _, merr := stripeQ.UpsertSubscriptionMapping(ctx, internalstripe.UpsertSubscriptionMappingParams{
SubscriptionID: created.SubscriptionID,
StripeSubscriptionID: sql.NullString{String: stripeSubscriptionID, Valid: true},
SyncStatus: "synced",
Livemode: sql.NullBool{Bool: sub.Livemode, Valid: true},
}); merr != nil {
return "", fmt.Errorf("reconcile: upsert subscription mapping: %w", merr)
}
@@ -363,10 +384,15 @@ func reconcileItems(
if cerr != nil {
return fmt.Errorf("reconcile: create subscription item: %w", cerr)
}
// A Stripe subscription item carries no Livemode of its own, so
// the item mapping records the parent subscription's flag: an
// item lives in the environment its subscription lives in
// (stripe-environment-stamp D1, D4).
if _, ierr := stripeQ.UpsertSubscriptionItemMapping(ctx, internalstripe.UpsertSubscriptionItemMappingParams{
SubscriptionItemID: subItem.SubscriptionItemID,
StripeSubscriptionItemID: sql.NullString{String: item.ID, Valid: true},
SyncStatus: "synced",
Livemode: sql.NullBool{Bool: sub.Livemode, Valid: true},
}); ierr != nil {
return fmt.Errorf("reconcile: upsert subscription item mapping: %w", ierr)
}
@@ -177,6 +177,11 @@ func (fx fixture) activeSub(items ...*stripe.SubscriptionItem) *stripe.Subscript
CurrentPeriodStart: now,
CurrentPeriodEnd: now + 2592000,
Items: &stripe.SubscriptionItemList{Data: items},
// Every Stripe object carries livemode, and the reconcile stamps the
// subscription and item mappings from it (stripe-environment-stamp
// D1, D4). Stated rather than left to the zero value, so a canned
// object never claims an environment by accident.
Livemode: false,
}
}
@@ -1020,3 +1025,43 @@ func addRankZeroDefault(t *testing.T, database *sql.DB, fx fixture) string {
}
return prod.ProductID
}
// TestReconcile_StampsTheEnvironmentFromTheObject pins the Risks section's
// guard: the stamp on both mapping tables comes from the subscription
// Stripe returned, never from the API key. The canned subscription reports
// live while the mock backend runs under sk_test_mock, so a writer that
// read the key instead of the object would record the opposite.
func TestReconcile_StampsTheEnvironmentFromTheObject(t *testing.T) {
database := testDB(t)
fx := seedFixture(t, database)
sub := fx.activeSub(fx.planItem())
sub.Livemode = true
t.Cleanup(stripetest.Install(&stripetest.MockBackend{Sub: sub}))
ctx := context.Background()
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), fx.stripeSubID, "test:environment-stamp"); err != nil {
t.Fatalf("reconcile: %v", err)
}
var subMode sql.NullBool
if err := database.QueryRowContext(ctx,
`SELECT livemode FROM stripe.subscription_mappings WHERE stripe_subscription_id=$1`,
fx.stripeSubID).Scan(&subMode); err != nil {
t.Fatalf("read subscription mapping livemode: %v", err)
}
if !subMode.Valid || !subMode.Bool {
t.Errorf("subscription mapping livemode = %+v, want the object's true", subMode)
}
// The item mapping records its parent's flag, because a Stripe
// subscription item carries none of its own.
var itemMode sql.NullBool
if err := database.QueryRowContext(ctx,
`SELECT livemode FROM stripe.subscription_item_mappings WHERE stripe_subscription_item_id=$1`,
"si_plan_"+fx.sfx).Scan(&itemMode); err != nil {
t.Fatalf("read subscription item mapping livemode: %v", err)
}
if !itemMode.Valid || !itemMode.Bool {
t.Errorf("subscription item mapping livemode = %+v, want the parent's true", itemMode)
}
}
+60 -6
View File
@@ -55,6 +55,28 @@ func fireScheduledChange(ctx context.Context, db *sql.DB, logger *slog.Logger, c
billingQ := billing.New(db)
stripeQ := internalstripe.New(db)
subMapping, err := stripeQ.GetSubscriptionMappingBySubscriptionID(ctx, ch.SubscriptionID)
if err != nil || !subMapping.StripeSubscriptionID.Valid {
return fmt.Errorf("fire: resolve stripe subscription for %s: %w", ch.SubscriptionID, err)
}
stripeSubID := subMapping.StripeSubscriptionID.String
// Read the ids the firing would send before the row is claimed. A
// claimed row is spent: its status moves scheduled -> applied in the
// same statement, so a change skipped after the claim would never fire
// again once the ids became reachable. Skipping first leaves the row
// scheduled for a later run (stripe-environment-stamp D5).
keyMode := internalstripe.ModeForKey(stripe.Key)
for _, m := range scheduledChangeMappings(ctx, billingQ, stripeQ, ch, subMapping, stripeSubID) {
if internalstripe.MappingReachable(m.livemode, m.syncStatus, keyMode) {
continue
}
logUnreachableMapping(logger,
"sweep: price mapping unreachable under the current key; skipping scheduled change",
ch.ScheduledChangeID, m.stripeID, m.livemode, m.syncStatus, keyMode)
return nil
}
// Claim: scheduled -> applied. A loser gets ErrNoRows and no-ops.
if _, err := billingQ.MarkScheduledChangeApplied(ctx, ch.ScheduledChangeID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -63,12 +85,6 @@ func fireScheduledChange(ctx context.Context, db *sql.DB, logger *slog.Logger, c
return fmt.Errorf("fire: claim %s: %w", ch.ScheduledChangeID, err)
}
subMapping, err := stripeQ.GetSubscriptionMappingBySubscriptionID(ctx, ch.SubscriptionID)
if err != nil || !subMapping.StripeSubscriptionID.Valid {
return fmt.Errorf("fire: resolve stripe subscription for %s: %w", ch.SubscriptionID, err)
}
stripeSubID := subMapping.StripeSubscriptionID.String
switch ch.ChangeType {
case "cancellation":
// Cancel at Stripe unless it is already gone, then converge.
@@ -113,3 +129,41 @@ func fireScheduledChange(ctx context.Context, db *sql.DB, logger *slog.Logger, c
return nil
}
}
// stampedMapping is one recorded Stripe id and the environment stamp on
// the mapping row that holds it.
type stampedMapping struct {
stripeID string
livemode sql.NullBool
syncStatus string
}
// scheduledChangeMappings lists every mapping whose id firing ch would
// send: the subscription always, plus the target price and the item the
// switch would move for a plan switch. A read that fails contributes
// nothing rather than blocking the sweep, because the firing path below
// reads the same rows again and reports the failure with its own message.
func scheduledChangeMappings(
ctx context.Context,
billingQ *billing.Queries,
stripeQ *internalstripe.Queries,
ch billing.SubscriptionScheduledChange,
subMapping internalstripe.SubscriptionMapping,
stripeSubID string,
) []stampedMapping {
out := []stampedMapping{{stripeSubID, subMapping.Livemode, subMapping.SyncStatus}}
if ch.ChangeType != "plan_switch" || !ch.TargetPriceID.Valid {
return out
}
if pm, err := stripeQ.GetPriceMappingByPriceID(ctx, ch.TargetPriceID.UUID.String()); err == nil && pm.StripePriceID.Valid {
out = append(out, stampedMapping{pm.StripePriceID.String, pm.Livemode, pm.SyncStatus})
}
items, err := billingQ.GetSubscriptionItemsBySubscriptionID(ctx, ch.SubscriptionID)
if err != nil || len(items) == 0 {
return out
}
if im, err := stripeQ.GetSubscriptionItemMappingByItemID(ctx, items[0].SubscriptionItemID); err == nil && im.StripeSubscriptionItemID.Valid {
out = append(out, stampedMapping{im.StripeSubscriptionItemID.String, im.Livemode, im.SyncStatus})
}
return out
}
+60 -5
View File
@@ -32,18 +32,32 @@ type Key string
// overview's setup banner has been dismissed, deployment-wide (design D12).
const SetupBannerDismissed Key = "setup_banner_dismissed"
// settingKind names the JSON shape a key's value holds, so GetBool/SetBool
// can refuse a key that was never declared as boolean rather than silently
// coercing whatever JSON happens to be stored under it.
// StripeEnvironmentCheck records which API key the last Stripe environment
// check ran under and what it found (stripe-environment-stamp D3). The
// value is a JSON object; its fields belong to the Stripe package, which
// marshals and unmarshals them, so this package stores the bytes and does
// not name the shape.
const StripeEnvironmentCheck Key = "stripe.environment_check"
// settingKind names the JSON shape a key's value holds, so the accessors
// can refuse a key that was never declared as their shape rather than
// silently coercing whatever JSON happens to be stored under it.
type settingKind string
const kindBool settingKind = "bool"
const (
kindBool settingKind = "bool"
// kindJSON is a JSON object whose fields the caller owns. This package
// carries the bytes and the registry entry; the shape lives with the
// code that has a reason to know it.
kindJSON settingKind = "json"
)
// registry is the allowed-keys list: every key this package will read or
// write, and the value shape it holds. A key absent from this map is not a
// real instance setting, whether or not a row happens to exist for it.
var registry = map[Key]settingKind{
SetupBannerDismissed: kindBool,
SetupBannerDismissed: kindBool,
StripeEnvironmentCheck: kindJSON,
}
// Store is the typed accessor over core.instance_settings. Built from a
@@ -103,3 +117,44 @@ func (s *Store) SetBool(ctx context.Context, key Key, value bool, updatedBy stri
Key: string(key), Value: raw, UpdatedBy: by,
})
}
// GetJSON reads a JSON-object instance setting as the stored bytes. The
// second return is false when no row exists, which GetBool can fold into
// its zero value but a JSON object cannot: "never written" and "written as
// {}" are different answers, and the caller needs to tell them apart. The
// caller unmarshals the bytes into its own shape, so this package never
// names one.
func (s *Store) GetJSON(ctx context.Context, key Key) (json.RawMessage, bool, error) {
if kind, ok := registry[key]; !ok || kind != kindJSON {
return nil, false, fmt.Errorf("instance: %q is not a declared JSON setting", key)
}
row, err := s.q.GetInstanceSetting(ctx, string(key))
if errors.Is(err, sql.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return json.RawMessage(row.Value), true, nil
}
// SetJSON writes a JSON-object instance setting, recording who set it.
// value is marshalled here, so a caller passes its own struct. updatedBy
// follows SetBool: stored as-is, empty stores NULL. The upsert stamps
// updated_at, which is how a reader dates the record.
func (s *Store) SetJSON(ctx context.Context, key Key, value any, updatedBy string) error {
if kind, ok := registry[key]; !ok || kind != kindJSON {
return fmt.Errorf("instance: %q is not a declared JSON setting", key)
}
raw, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("instance: encode %q: %w", key, err)
}
by := sql.NullString{}
if updatedBy != "" {
by = sql.NullString{String: updatedBy, Valid: true}
}
return s.q.UpsertInstanceSetting(ctx, UpsertInstanceSettingParams{
Key: string(key), Value: raw, UpdatedBy: by,
})
}
+90
View File
@@ -6,6 +6,7 @@ package instance_test
import (
"context"
"database/sql"
"encoding/json"
"os"
"testing"
@@ -117,3 +118,92 @@ func TestStore_UndeclaredKey(t *testing.T) {
t.Error("SetBool on an undeclared key returned no error")
}
}
// TestStore_JSON_DB exercises the JSON-object kind against a real
// database: an unwritten key reads as not-present rather than as an empty
// object, SetJSON round-trips through GetJSON, and a second SetJSON
// replaces the whole object and the recorded actor.
func TestStore_JSON_DB(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set, skipping integration test")
}
database, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { database.Close() })
if err := db.RunMigrations(database, migrate.Sources()); err != nil {
t.Fatalf("run migrations: %v", err)
}
ctx := context.Background()
store := instance.NewStore(database)
t.Cleanup(func() {
if _, err := database.ExecContext(ctx,
`DELETE FROM core.instance_settings WHERE key = $1`, string(instance.StripeEnvironmentCheck)); err != nil {
t.Errorf("cleanup: %v", err)
}
})
type record struct {
KeyFingerprint string `json:"key_fingerprint"`
Checked int `json:"checked"`
Stale int `json:"stale"`
}
if _, ok, err := store.GetJSON(ctx, instance.StripeEnvironmentCheck); err != nil {
t.Fatalf("GetJSON on unset key: %v", err)
} else if ok {
t.Error("GetJSON on unset key reported a record")
}
if err := store.SetJSON(ctx, instance.StripeEnvironmentCheck,
record{KeyFingerprint: "abc123", Checked: 36, Stale: 2}, "op@example.com"); err != nil {
t.Fatalf("SetJSON: %v", err)
}
raw, ok, err := store.GetJSON(ctx, instance.StripeEnvironmentCheck)
if err != nil {
t.Fatalf("GetJSON after SetJSON: %v", err)
}
if !ok {
t.Fatal("GetJSON after SetJSON reported no record")
}
var got record
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("decode stored record: %v", err)
}
if got.KeyFingerprint != "abc123" || got.Checked != 36 || got.Stale != 2 {
t.Errorf("stored record = %+v, want {abc123 36 2}", got)
}
var updatedBy sql.NullString
if err := database.QueryRowContext(ctx,
`SELECT updated_by FROM core.instance_settings WHERE key = $1`, string(instance.StripeEnvironmentCheck),
).Scan(&updatedBy); err != nil {
t.Fatalf("read updated_by: %v", err)
}
if !updatedBy.Valid || updatedBy.String != "op@example.com" {
t.Errorf("updated_by = %+v, want op@example.com", updatedBy)
}
// Overwrite: the whole object is replaced, not merged.
if err := store.SetJSON(ctx, instance.StripeEnvironmentCheck,
record{KeyFingerprint: "def456"}, "other@example.com"); err != nil {
t.Fatalf("SetJSON (update path): %v", err)
}
raw, _, err = store.GetJSON(ctx, instance.StripeEnvironmentCheck)
if err != nil {
t.Fatalf("GetJSON after second SetJSON: %v", err)
}
got = record{}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("decode replaced record: %v", err)
}
if got.KeyFingerprint != "def456" || got.Checked != 0 || got.Stale != 0 {
t.Errorf("replaced record = %+v, want {def456 0 0}", got)
}
}
+40
View File
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package instance_test
import (
"context"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/instance"
)
// TestStore_KindMismatch pins the registry's second guard: a key declared
// as one shape is refused by the other shape's accessor, so a JSON object
// is never decoded as a boolean nor a boolean overwritten by an object.
// The guard answers before the store reaches the database, which is why
// this test needs none.
func TestStore_KindMismatch(t *testing.T) {
store := instance.NewStore(nil)
ctx := context.Background()
if _, err := store.GetBool(ctx, instance.StripeEnvironmentCheck); err == nil {
t.Error("GetBool on a JSON key returned no error")
}
if err := store.SetBool(ctx, instance.StripeEnvironmentCheck, true, ""); err == nil {
t.Error("SetBool on a JSON key returned no error")
}
if _, _, err := store.GetJSON(ctx, instance.SetupBannerDismissed); err == nil {
t.Error("GetJSON on a boolean key returned no error")
}
if err := store.SetJSON(ctx, instance.SetupBannerDismissed, struct{}{}, ""); err == nil {
t.Error("SetJSON on a boolean key returned no error")
}
if _, _, err := store.GetJSON(ctx, instance.Key("not_a_real_setting")); err == nil {
t.Error("GetJSON on an undeclared key returned no error")
}
if err := store.SetJSON(ctx, instance.Key("not_a_real_setting"), struct{}{}, ""); err == nil {
t.Error("SetJSON on an undeclared key returned no error")
}
}
@@ -13,8 +13,20 @@ import (
"database/sql"
)
const countCustomerMappingsOutsideMode = `-- name: CountCustomerMappingsOutsideMode :one
SELECT COUNT(*) FROM stripe.customer_mappings
WHERE livemode IS NOT NULL AND livemode <> $1::boolean
`
func (q *Queries) CountCustomerMappingsOutsideMode(ctx context.Context, keyLivemode bool) (int64, error) {
row := q.db.QueryRowContext(ctx, countCustomerMappingsOutsideMode, keyLivemode)
var count int64
err := row.Scan(&count)
return count, err
}
const getCustomerMappingByBillingAccountID = `-- name: GetCustomerMappingByBillingAccountID :one
SELECT billing_account_id, stripe_customer_id, sync_status, created_at, updated_at FROM stripe.customer_mappings
SELECT billing_account_id, stripe_customer_id, sync_status, created_at, updated_at, livemode FROM stripe.customer_mappings
WHERE billing_account_id = $1
`
@@ -27,12 +39,13 @@ func (q *Queries) GetCustomerMappingByBillingAccountID(ctx context.Context, bill
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
)
return i, err
}
const getCustomerMappingByStripeCustomerID = `-- name: GetCustomerMappingByStripeCustomerID :one
SELECT billing_account_id, stripe_customer_id, sync_status, created_at, updated_at FROM stripe.customer_mappings
SELECT billing_account_id, stripe_customer_id, sync_status, created_at, updated_at, livemode FROM stripe.customer_mappings
WHERE stripe_customer_id = $1
`
@@ -45,10 +58,43 @@ func (q *Queries) GetCustomerMappingByStripeCustomerID(ctx context.Context, stri
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
)
return i, err
}
const listBillingAccountIDsOutsideMode = `-- name: ListBillingAccountIDsOutsideMode :many
SELECT billing_account_id FROM stripe.customer_mappings
WHERE livemode IS NOT NULL AND livemode <> $1::boolean
`
// The billing accounts the operator views exclude under the current key
// (stripe-environment-stamp D7): a mapping whose recorded environment is
// set and differs from the key's. A NULL livemode is unverified and shows
// under either key, so it is not in this list.
func (q *Queries) ListBillingAccountIDsOutsideMode(ctx context.Context, keyLivemode bool) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listBillingAccountIDsOutsideMode, keyLivemode)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var billing_account_id string
if err := rows.Scan(&billing_account_id); err != nil {
return nil, err
}
items = append(items, billing_account_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateCustomerMappingSyncStatus = `-- name: UpdateCustomerMappingSyncStatus :exec
UPDATE stripe.customer_mappings
SET sync_status = $2, updated_at = NOW()
@@ -66,23 +112,36 @@ func (q *Queries) UpdateCustomerMappingSyncStatus(ctx context.Context, arg Updat
}
const upsertCustomerMapping = `-- name: UpsertCustomerMapping :one
INSERT INTO stripe.customer_mappings (billing_account_id, stripe_customer_id, sync_status)
VALUES ($1, $2, $3)
INSERT INTO stripe.customer_mappings (billing_account_id, stripe_customer_id, sync_status, livemode)
VALUES ($1, $2, $3, $4)
ON CONFLICT (billing_account_id) DO UPDATE
SET stripe_customer_id = EXCLUDED.stripe_customer_id,
sync_status = EXCLUDED.sync_status,
livemode = COALESCE(EXCLUDED.livemode, customer_mappings.livemode),
updated_at = NOW()
RETURNING billing_account_id, stripe_customer_id, sync_status, created_at, updated_at
RETURNING billing_account_id, stripe_customer_id, sync_status, created_at, updated_at, livemode
`
type UpsertCustomerMappingParams struct {
BillingAccountID string `json:"billing_account_id"`
StripeCustomerID sql.NullString `json:"stripe_customer_id"`
SyncStatus string `json:"sync_status"`
Livemode sql.NullBool `json:"livemode"`
}
// livemode records the Stripe environment the id was created in
// (stripe-environment-stamp D1), taken from the customer object Stripe
// returned, never from the API key. On the conflict branch it is
// COALESCEd with the stored value: a caller that has no flag to offer
// passes NULL, and NULL must not erase an environment the console already
// knows.
func (q *Queries) UpsertCustomerMapping(ctx context.Context, arg UpsertCustomerMappingParams) (CustomerMapping, error) {
row := q.db.QueryRowContext(ctx, upsertCustomerMapping, arg.BillingAccountID, arg.StripeCustomerID, arg.SyncStatus)
row := q.db.QueryRowContext(ctx, upsertCustomerMapping,
arg.BillingAccountID,
arg.StripeCustomerID,
arg.SyncStatus,
arg.Livemode,
)
var i CustomerMapping
err := row.Scan(
&i.BillingAccountID,
@@ -90,6 +149,7 @@ func (q *Queries) UpsertCustomerMapping(ctx context.Context, arg UpsertCustomerM
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
)
return i, err
}
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package stripe
import (
"encoding/json"
"time"
)
// EnvironmentCheckRecord is what the console remembers about the last
// environment check: which key it ran under, when it ran, and what it
// found (stripe-environment-stamp D3). It is stored as the JSON value of
// the `stripe.environment_check` instance setting.
//
// The shape lives in this package because the two sides of it are in
// different trees: the check activity (internal/integrations/stripe/
// workflows) writes it and the provider page (internal/server) reads it,
// and this store is the one package both already import.
//
// Checked counts every mapping the run read back, Stale the subset Stripe
// answered resource_missing for, so the verified count is Checked minus
// Stale. Started and Finished are pointers because a record written at the
// start of a run has no finish: a record with StartedAt set and FinishedAt
// nil is a run in progress, which is how the page tells "checking" from
// "checked".
type EnvironmentCheckRecord struct {
KeyFingerprint string `json:"key_fingerprint"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Checked int `json:"checked"`
Stale int `json:"stale"`
}
// Verified is the count the page and the control's toast name: the rows
// that resolved under the key the run held.
func (r EnvironmentCheckRecord) Verified() int {
if r.Checked < r.Stale {
return 0
}
return r.Checked - r.Stale
}
// Running reports whether the record describes a run that started and has
// not written its finish.
func (r EnvironmentCheckRecord) Running() bool {
return r.StartedAt != nil && r.FinishedAt == nil
}
// DecodeEnvironmentCheckRecord reads the stored setting value. found is
// what the instance store answered for the key's row; a key with no row
// returns the zero record and false, and so does a row whose bytes do not
// decode, because a record the console cannot read tells it nothing about
// which key the last check ran under.
func DecodeEnvironmentCheckRecord(raw json.RawMessage, found bool) (EnvironmentCheckRecord, bool) {
if !found || len(raw) == 0 {
return EnvironmentCheckRecord{}, false
}
var rec EnvironmentCheckRecord
if err := json.Unmarshal(raw, &rec); err != nil {
return EnvironmentCheckRecord{}, false
}
return rec, true
}
// EnvironmentCheckNeeded answers boot's one question: does the check need
// to run again (stripe-environment-stamp D3)? No record at all is a yes,
// which is the first boot after this change and every deployment's first
// boot; an unreadable record is a yes for the same reason; a record under
// another key is a yes, because the ids were read back under a key that is
// no longer in force.
//
// A record with no finish is also a yes. The run writes the fingerprint
// when it starts, so a run that died part way through, or exhausted its
// retries, leaves a record that matches the key and describes no outcome.
// Without this case the next boot would skip the check and the provider
// page would read "Checking ..." until an operator pressed the control.
//
// Only a record under this key that wrote its finish is a no, and boot
// touches nothing.
func EnvironmentCheckNeeded(raw json.RawMessage, found bool, fingerprint string) bool {
rec, ok := DecodeEnvironmentCheckRecord(raw, found)
if !ok {
return true
}
if rec.KeyFingerprint != fingerprint {
return true
}
return rec.FinishedAt == nil
}
// FingerprintPrefix is the first eight characters of a key fingerprint,
// the form the log lines carry: enough to tell one key from another
// across two boots, and not enough to be the digest of a key someone is
// guessing at.
func FingerprintPrefix(fingerprint string) string {
if len(fingerprint) <= 8 {
return fingerprint
}
return fingerprint[:8]
}
@@ -0,0 +1,103 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package stripe
import (
"encoding/json"
"testing"
"time"
)
// TestEnvironmentCheckNeeded pins boot's one decision
// (stripe-environment-stamp D3): the key in force against the key the last
// check ran under. It is tested here rather than through cmd/start.go
// because the decision is the whole of what boot contributes; the clears
// and the workflow start that follow it are two queries and one
// ExecuteWorkflow.
func TestEnvironmentCheckNeeded(t *testing.T) {
started := time.Date(2026, 9, 18, 15, 4, 0, 0, time.UTC)
record := func(fingerprint string) json.RawMessage {
raw, err := json.Marshal(EnvironmentCheckRecord{
KeyFingerprint: fingerprint,
StartedAt: &started,
FinishedAt: &started,
Checked: 36,
Stale: 2,
})
if err != nil {
t.Fatalf("marshal record: %v", err)
}
return raw
}
// A record the run wrote at its start and never finished: the run died
// part way through, or exhausted its retries, and the fingerprint it
// wrote would otherwise make every later boot skip the check.
unfinished, err := json.Marshal(EnvironmentCheckRecord{
KeyFingerprint: "abc123",
StartedAt: &started,
})
if err != nil {
t.Fatalf("marshal unfinished record: %v", err)
}
cases := []struct {
name string
raw json.RawMessage
found bool
fingerprint string
want bool
}{
{"no record at all", nil, false, "abc123", true},
{"a record under another key", record("deadbeef"), true, "abc123", true},
{"a record under this key", record("abc123"), true, "abc123", false},
{"a record nothing can read", json.RawMessage(`{`), true, "abc123", true},
{"a row holding nothing", json.RawMessage{}, true, "abc123", true},
{"a run that never finished", unfinished, true, "abc123", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := EnvironmentCheckNeeded(tc.raw, tc.found, tc.fingerprint); got != tc.want {
t.Errorf("EnvironmentCheckNeeded = %v, want %v", got, tc.want)
}
})
}
}
// TestEnvironmentCheckRecordReading pins what the provider page asks a
// record: whether a run is still going, and how many ids it verified.
func TestEnvironmentCheckRecordReading(t *testing.T) {
at := time.Date(2026, 9, 18, 15, 4, 0, 0, time.UTC)
running := EnvironmentCheckRecord{KeyFingerprint: "abc", StartedAt: &at}
if !running.Running() {
t.Error("a record with a start and no finish is a run in progress")
}
finished := EnvironmentCheckRecord{KeyFingerprint: "abc", StartedAt: &at, FinishedAt: &at, Checked: 36, Stale: 2}
if finished.Running() {
t.Error("a record with a finish is not a run in progress")
}
if got := finished.Verified(); got != 34 {
t.Errorf("Verified() = %d, want 34", got)
}
// A record that somehow counts more stale rows than checked ones says
// nothing negative; the page prints a count, not a defect.
odd := EnvironmentCheckRecord{Checked: 1, Stale: 3}
if got := odd.Verified(); got != 0 {
t.Errorf("Verified() = %d, want 0", got)
}
}
// TestFingerprintPrefix pins the form the log lines carry.
func TestFingerprintPrefix(t *testing.T) {
if got := FingerprintPrefix("0123456789abcdef"); got != "01234567" {
t.Errorf("FingerprintPrefix = %q, want %q", got, "01234567")
}
if got := FingerprintPrefix("abc"); got != "abc" {
t.Errorf("a short fingerprint is carried whole, got %q", got)
}
if got := FingerprintPrefix(""); got != "" {
t.Errorf("no key has no fingerprint, got %q", got)
}
}
@@ -13,8 +13,20 @@ import (
"database/sql"
)
const countInvoiceMappingsOutsideMode = `-- name: CountInvoiceMappingsOutsideMode :one
SELECT COUNT(*) FROM stripe.invoice_mappings
WHERE livemode IS NOT NULL AND livemode <> $1::boolean
`
func (q *Queries) CountInvoiceMappingsOutsideMode(ctx context.Context, keyLivemode bool) (int64, error) {
row := q.db.QueryRowContext(ctx, countInvoiceMappingsOutsideMode, keyLivemode)
var count int64
err := row.Scan(&count)
return count, err
}
const getInvoiceMappingByInvoiceID = `-- name: GetInvoiceMappingByInvoiceID :one
SELECT invoice_id, stripe_invoice_id, sync_status, created_at, updated_at, stripe_invoice_number FROM stripe.invoice_mappings
SELECT invoice_id, stripe_invoice_id, sync_status, created_at, updated_at, stripe_invoice_number, livemode FROM stripe.invoice_mappings
WHERE invoice_id = $1
`
@@ -28,12 +40,13 @@ func (q *Queries) GetInvoiceMappingByInvoiceID(ctx context.Context, invoiceID st
&i.CreatedAt,
&i.UpdatedAt,
&i.StripeInvoiceNumber,
&i.Livemode,
)
return i, err
}
const getInvoiceMappingByStripeID = `-- name: GetInvoiceMappingByStripeID :one
SELECT invoice_id, stripe_invoice_id, sync_status, created_at, updated_at, stripe_invoice_number FROM stripe.invoice_mappings
SELECT invoice_id, stripe_invoice_id, sync_status, created_at, updated_at, stripe_invoice_number, livemode FROM stripe.invoice_mappings
WHERE stripe_invoice_id = $1
`
@@ -47,14 +60,15 @@ func (q *Queries) GetInvoiceMappingByStripeID(ctx context.Context, stripeInvoice
&i.CreatedAt,
&i.UpdatedAt,
&i.StripeInvoiceNumber,
&i.Livemode,
)
return i, err
}
const insertInvoiceMapping = `-- name: InsertInvoiceMapping :one
INSERT INTO stripe.invoice_mappings (invoice_id, stripe_invoice_id, stripe_invoice_number, sync_status)
VALUES ($1, $2, $3, $4)
RETURNING invoice_id, stripe_invoice_id, sync_status, created_at, updated_at, stripe_invoice_number
INSERT INTO stripe.invoice_mappings (invoice_id, stripe_invoice_id, stripe_invoice_number, sync_status, livemode)
VALUES ($1, $2, $3, $4, $5)
RETURNING invoice_id, stripe_invoice_id, sync_status, created_at, updated_at, stripe_invoice_number, livemode
`
type InsertInvoiceMappingParams struct {
@@ -62,18 +76,22 @@ type InsertInvoiceMappingParams struct {
StripeInvoiceID sql.NullString `json:"stripe_invoice_id"`
StripeInvoiceNumber sql.NullString `json:"stripe_invoice_number"`
SyncStatus string `json:"sync_status"`
Livemode sql.NullBool `json:"livemode"`
}
// stripe_invoice_number is Stripe's own customer-facing number (invoice-numbers
// D5), carried here as received from the invoice.finalized payload's
// `number` field -- an external reference, never the invoice's identity
// (that is core.invoices.invoice_number, the platform-assigned one).
// livemode is the invoice object's own flag inside the stored payload
// (stripe-environment-stamp D1), not the event envelope's.
func (q *Queries) InsertInvoiceMapping(ctx context.Context, arg InsertInvoiceMappingParams) (InvoiceMapping, error) {
row := q.db.QueryRowContext(ctx, insertInvoiceMapping,
arg.InvoiceID,
arg.StripeInvoiceID,
arg.StripeInvoiceNumber,
arg.SyncStatus,
arg.Livemode,
)
var i InvoiceMapping
err := row.Scan(
@@ -83,10 +101,44 @@ func (q *Queries) InsertInvoiceMapping(ctx context.Context, arg InsertInvoiceMap
&i.CreatedAt,
&i.UpdatedAt,
&i.StripeInvoiceNumber,
&i.Livemode,
)
return i, err
}
const listInvoiceIDsOutsideMode = `-- name: ListInvoiceIDsOutsideMode :many
SELECT invoice_id FROM stripe.invoice_mappings
WHERE livemode IS NOT NULL AND livemode <> $1::boolean
`
// The invoices the operator view excludes under the current key
// (stripe-environment-stamp D7), resolved here and handed to
// ListInvoicesPage the same way the Stripe-number search feeds it ids. A
// NULL livemode is unverified and shows under either key, so it is not in
// this list.
func (q *Queries) ListInvoiceIDsOutsideMode(ctx context.Context, keyLivemode bool) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listInvoiceIDsOutsideMode, keyLivemode)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var invoice_id string
if err := rows.Scan(&invoice_id); err != nil {
return nil, err
}
items = append(items, invoice_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listInvoiceMappingsByStripeNumberSubstring = `-- name: ListInvoiceMappingsByStripeNumberSubstring :many
SELECT invoice_id FROM stripe.invoice_mappings
WHERE stripe_invoice_number ILIKE '%' || $1 || '%'
@@ -0,0 +1,125 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package stripe
import (
"database/sql"
"strings"
)
// ModeTest and ModeLive are the two modes a Stripe API key can be in; the
// empty string is the third state, a deployment with no usable key, under
// which nothing is a disagreement because there is nothing to disagree
// with.
//
// The words live here rather than in the parent package because the
// parent cannot be imported from internal/fulfillment or from the Stripe
// workflows package: the parent's adapter pulls in the web and workflows
// packages, and the workflows package calls back into fulfillment, so an
// import either way closes a cycle. This store is the one package all
// three already read through.
const (
ModeTest = "test"
ModeLive = "live"
)
// SyncStatusStale is the sync_status the environment check writes on a
// mapping whose Stripe id answered resource_missing under the current key
// (stripe-environment-stamp D2). The object still exists in the
// environment that made it, so the recorded livemode can agree with the
// key and the id still be out of reach; every consumer treats a stale row
// as it treats a disagreeing one.
const SyncStatusStale = "stale"
// ModeForKey derives the mode from the API key's prefix: `sk_live_` and
// `rk_live_` (restricted live) are live, `sk_test_` and `rk_test_` are
// test, and anything else, including an empty key, is unknown. The mode is
// a property of the key, so a caller holding the key it is about to call
// Stripe with can read the mode from it and cannot disagree with itself.
//
// Unlike the parent package's ModeForKey, which fails boot on a key it
// does not recognize, this one answers "" and leaves the refusal to boot:
// a request-time or activity-time caller has no business failing over a
// key boot already accepted.
func ModeForKey(apiKey string) string {
key := strings.TrimSpace(apiKey)
switch {
case strings.HasPrefix(key, "sk_live_"), strings.HasPrefix(key, "rk_live_"):
return ModeLive
case strings.HasPrefix(key, "sk_test_"), strings.HasPrefix(key, "rk_test_"):
return ModeTest
}
return ""
}
// MappingAgrees answers whether a mapping's recorded Stripe environment can
// be reached under a key in keyMode, so every consumer asks one question
// (stripe-environment-stamp D2).
//
// A NULL livemode blocks nothing. It means the row predates the stamp, or
// was written by a path that had no object to read the flag from: the
// console does not know which environment made the id, and refusing on
// that would break a working deployment between the migration and the
// first environment check. Only a flag that is set and differs is a
// disagreement.
//
// This says nothing about sync_status. A `stale` row can hold a flag that
// agrees and still be unreachable, because two Stripe environments can
// both report livemode false; a consumer checks that separately.
func MappingAgrees(livemode sql.NullBool, keyMode string) bool {
if !livemode.Valid {
return true
}
if livemode.Bool {
return keyMode == ModeLive
}
return keyMode == ModeTest
}
// MappingReachable is the whole question design D5's consumers ask before
// they send a recorded Stripe id: can a key in keyMode reach the object
// this mapping points at. It is false when the recorded environment
// disagrees with the key, and false when the environment check already
// asked Stripe for the id under this key and was answered
// resource_missing.
//
// An empty keyMode answers true. A deployment whose key the console could
// not classify knows nothing about which environment it is in, and
// refusing every id on that would take a working deployment down over a
// question it cannot answer.
func MappingReachable(livemode sql.NullBool, syncStatus, keyMode string) bool {
if keyMode == "" {
return true
}
if syncStatus == SyncStatusStale {
return false
}
return MappingAgrees(livemode, keyMode)
}
// RecordedMode names the environment a mapping records, for the log lines
// design D5 specifies: `live`, `test`, `stale` for a row the check could
// not reach under the current key, and `unverified` for a row holding no
// flag.
func RecordedMode(livemode sql.NullBool, syncStatus string) string {
if syncStatus == SyncStatusStale {
return SyncStatusStale
}
if !livemode.Valid {
return "unverified"
}
if livemode.Bool {
return ModeLive
}
return ModeTest
}
// OtherMode names the environment a key in keyMode is not in, the word the
// billing views' absence line uses for the rows it hides (design D7).
func OtherMode(keyMode string) string {
if keyMode == ModeLive {
return ModeTest
}
return ModeLive
}
@@ -0,0 +1,140 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package stripe_test
import (
"database/sql"
"testing"
stripestore "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
)
// TestMappingAgrees pins the three cases of stripe-environment-stamp D2:
// an unverified mapping agrees with any key, a stamped mapping agrees only
// with its own environment, and the empty mode an unconfigured deployment
// carries matches neither stamp.
func TestMappingAgrees(t *testing.T) {
live := sql.NullBool{Bool: true, Valid: true}
test := sql.NullBool{Bool: false, Valid: true}
unverified := sql.NullBool{}
cases := []struct {
name string
livemode sql.NullBool
keyMode string
want bool
}{
{"unverified under a live key", unverified, "live", true},
{"unverified under a test key", unverified, "test", true},
{"unverified with no key configured", unverified, "", true},
{"live mapping under a live key", live, "live", true},
{"live mapping under a test key", live, "test", false},
{"test mapping under a test key", test, "test", true},
{"test mapping under a live key", test, "live", false},
{"live mapping with no key configured", live, "", false},
{"test mapping with no key configured", test, "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := stripestore.MappingAgrees(tc.livemode, tc.keyMode); got != tc.want {
t.Errorf("MappingAgrees(%+v, %q) = %v, want %v", tc.livemode, tc.keyMode, got, tc.want)
}
})
}
}
// TestMappingReachable pins the question design D5's six consumers ask.
// Unverified reaches, a stamp that agrees reaches, a stamp that disagrees
// does not, and a stale row does not whatever its flag says, because two
// Stripe environments can both report livemode false and only the
// read-back can tell them apart. An empty keyMode reaches everything: a
// deployment whose key the console could not classify must not refuse
// every id over a question it cannot answer.
func TestMappingReachable(t *testing.T) {
live := sql.NullBool{Bool: true, Valid: true}
test := sql.NullBool{Bool: false, Valid: true}
unverified := sql.NullBool{}
const stale = stripestore.SyncStatusStale
cases := []struct {
name string
livemode sql.NullBool
syncStatus string
keyMode string
want bool
}{
{"unverified under a live key", unverified, "synced", "live", true},
{"unverified under a test key", unverified, "synced", "test", true},
{"live mapping under a live key", live, "synced", "live", true},
{"test mapping under a test key", test, "synced", "test", true},
{"live mapping under a test key", live, "synced", "test", false},
{"test mapping under a live key", test, "synced", "live", false},
{"stale live mapping under a live key", live, stale, "live", false},
{"stale test mapping under a test key", test, stale, "test", false},
{"stale unverified mapping", unverified, stale, "live", false},
{"pending mapping is not stale", live, "pending", "live", true},
{"no key classifies, stamped", test, "synced", "", true},
{"no key classifies, stale", live, stale, "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := stripestore.MappingReachable(tc.livemode, tc.syncStatus, tc.keyMode)
if got != tc.want {
t.Errorf("MappingReachable(%+v, %q, %q) = %v, want %v",
tc.livemode, tc.syncStatus, tc.keyMode, got, tc.want)
}
})
}
}
// TestRecordedMode pins the word the D5 log lines put in recorded_mode.
// Stale wins over the flag, because the row's environment is no longer the
// fact the reader needs; an unset flag is named rather than guessed.
func TestRecordedMode(t *testing.T) {
live := sql.NullBool{Bool: true, Valid: true}
test := sql.NullBool{Bool: false, Valid: true}
unverified := sql.NullBool{}
cases := []struct {
name string
livemode sql.NullBool
syncStatus string
want string
}{
{"live", live, "synced", "live"},
{"test", test, "synced", "test"},
{"unverified", unverified, "synced", "unverified"},
{"stale outranks the live flag", live, stripestore.SyncStatusStale, "stale"},
{"stale outranks no flag", unverified, stripestore.SyncStatusStale, "stale"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := stripestore.RecordedMode(tc.livemode, tc.syncStatus); got != tc.want {
t.Errorf("RecordedMode(%+v, %q) = %q, want %q",
tc.livemode, tc.syncStatus, got, tc.want)
}
})
}
}
// TestOtherMode pins the word the billing views' absence line uses for the
// rows it hides. An unclassified key answers live, which no caller reaches:
// environmentNotice renders nothing without a mode.
func TestOtherMode(t *testing.T) {
cases := []struct{ keyMode, want string }{
{"live", "test"},
{"test", "live"},
{"", "live"},
}
for _, tc := range cases {
t.Run(tc.keyMode, func(t *testing.T) {
if got := stripestore.OtherMode(tc.keyMode); got != tc.want {
t.Errorf("OtherMode(%q) = %q, want %q", tc.keyMode, got, tc.want)
}
})
}
}
@@ -0,0 +1,54 @@
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- stripe-environment-stamp D1: a Stripe id exists only in the environment
-- that made it, so every mapping records the environment it was created
-- in. livemode is the boolean the SDK puts on every object the console
-- receives, written by whoever holds that object and never derived from
-- the API key.
--
-- This migration writes no data. Every existing row keeps NULL, which
-- reads as unverified: not live, not test, not a disagreement. Nothing is
-- guessed into an environment, because the console has no record of the
-- key each of those ids was created under, and a wrong stamp would refuse
-- a product that works.
--
-- `stale` (the environment check asked Stripe for the id under the current
-- key and Stripe answered resource_missing) joins pending, synced and
-- deleted as a sync_status value and needs no DDL here: sync_status is a
-- bare TEXT NOT NULL DEFAULT on all eight tables in 00001_init.sql with no
-- CHECK constraint, so the vocabulary lives in Go (knownStates and the
-- readiness branches), where the badge map and the tests can see it.
ALTER TABLE stripe.customer_mappings ADD COLUMN livemode BOOLEAN;
ALTER TABLE stripe.product_mappings ADD COLUMN livemode BOOLEAN;
ALTER TABLE stripe.price_mappings ADD COLUMN livemode BOOLEAN;
ALTER TABLE stripe.subscription_mappings ADD COLUMN livemode BOOLEAN;
ALTER TABLE stripe.subscription_item_mappings ADD COLUMN livemode BOOLEAN;
ALTER TABLE stripe.invoice_mappings ADD COLUMN livemode BOOLEAN;
ALTER TABLE stripe.payment_mappings ADD COLUMN livemode BOOLEAN;
ALTER TABLE stripe.payment_method_mappings ADD COLUMN livemode BOOLEAN;
-- Only products and prices are read back by the environment check (design
-- D3), so only they record when that read-back last confirmed the id. The
-- other six carry no verification column: nothing fetches them, and their
-- flag is a fact recorded at write time.
ALTER TABLE stripe.product_mappings ADD COLUMN verified_at TIMESTAMPTZ;
ALTER TABLE stripe.price_mappings ADD COLUMN verified_at TIMESTAMPTZ;
-- +goose Down
ALTER TABLE stripe.price_mappings DROP COLUMN verified_at;
ALTER TABLE stripe.product_mappings DROP COLUMN verified_at;
ALTER TABLE stripe.payment_method_mappings DROP COLUMN livemode;
ALTER TABLE stripe.payment_mappings DROP COLUMN livemode;
ALTER TABLE stripe.invoice_mappings DROP COLUMN livemode;
ALTER TABLE stripe.subscription_item_mappings DROP COLUMN livemode;
ALTER TABLE stripe.subscription_mappings DROP COLUMN livemode;
ALTER TABLE stripe.price_mappings DROP COLUMN livemode;
ALTER TABLE stripe.product_mappings DROP COLUMN livemode;
ALTER TABLE stripe.customer_mappings DROP COLUMN livemode;
@@ -18,6 +18,7 @@ type CustomerMapping struct {
SyncStatus string `json:"sync_status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Livemode sql.NullBool `json:"livemode"`
}
type InvoiceMapping struct {
@@ -27,6 +28,7 @@ type InvoiceMapping struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
StripeInvoiceNumber sql.NullString `json:"stripe_invoice_number"`
Livemode sql.NullBool `json:"livemode"`
}
type PaymentMapping struct {
@@ -35,6 +37,7 @@ type PaymentMapping struct {
SyncStatus string `json:"sync_status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Livemode sql.NullBool `json:"livemode"`
}
type PaymentMethodMapping struct {
@@ -43,6 +46,7 @@ type PaymentMethodMapping struct {
SyncStatus string `json:"sync_status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Livemode sql.NullBool `json:"livemode"`
}
type PriceMapping struct {
@@ -51,6 +55,8 @@ type PriceMapping struct {
SyncStatus string `json:"sync_status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Livemode sql.NullBool `json:"livemode"`
VerifiedAt sql.NullTime `json:"verified_at"`
}
type ProductMapping struct {
@@ -59,6 +65,8 @@ type ProductMapping struct {
SyncStatus string `json:"sync_status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Livemode sql.NullBool `json:"livemode"`
VerifiedAt sql.NullTime `json:"verified_at"`
}
type SubscriptionItemMapping struct {
@@ -67,6 +75,7 @@ type SubscriptionItemMapping struct {
SyncStatus string `json:"sync_status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Livemode sql.NullBool `json:"livemode"`
}
type SubscriptionMapping struct {
@@ -75,4 +84,5 @@ type SubscriptionMapping struct {
SyncStatus string `json:"sync_status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Livemode sql.NullBool `json:"livemode"`
}
@@ -13,8 +13,20 @@ import (
"database/sql"
)
const countPaymentMappingsOutsideMode = `-- name: CountPaymentMappingsOutsideMode :one
SELECT COUNT(*) FROM stripe.payment_mappings
WHERE livemode IS NOT NULL AND livemode <> $1::boolean
`
func (q *Queries) CountPaymentMappingsOutsideMode(ctx context.Context, keyLivemode bool) (int64, error) {
row := q.db.QueryRowContext(ctx, countPaymentMappingsOutsideMode, keyLivemode)
var count int64
err := row.Scan(&count)
return count, err
}
const getPaymentMappingByPaymentID = `-- name: GetPaymentMappingByPaymentID :one
SELECT payment_id, stripe_payment_intent_id, sync_status, created_at, updated_at FROM stripe.payment_mappings
SELECT payment_id, stripe_payment_intent_id, sync_status, created_at, updated_at, livemode FROM stripe.payment_mappings
WHERE payment_id = $1
`
@@ -27,12 +39,13 @@ func (q *Queries) GetPaymentMappingByPaymentID(ctx context.Context, paymentID st
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
)
return i, err
}
const getPaymentMappingByStripePaymentIntentID = `-- name: GetPaymentMappingByStripePaymentIntentID :one
SELECT payment_id, stripe_payment_intent_id, sync_status, created_at, updated_at FROM stripe.payment_mappings
SELECT payment_id, stripe_payment_intent_id, sync_status, created_at, updated_at, livemode FROM stripe.payment_mappings
WHERE stripe_payment_intent_id = $1
`
@@ -45,24 +58,34 @@ func (q *Queries) GetPaymentMappingByStripePaymentIntentID(ctx context.Context,
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
)
return i, err
}
const insertPaymentMapping = `-- name: InsertPaymentMapping :one
INSERT INTO stripe.payment_mappings (payment_id, stripe_payment_intent_id, sync_status)
VALUES ($1, $2, $3)
RETURNING payment_id, stripe_payment_intent_id, sync_status, created_at, updated_at
INSERT INTO stripe.payment_mappings (payment_id, stripe_payment_intent_id, sync_status, livemode)
VALUES ($1, $2, $3, $4)
RETURNING payment_id, stripe_payment_intent_id, sync_status, created_at, updated_at, livemode
`
type InsertPaymentMappingParams struct {
PaymentID string `json:"payment_id"`
StripePaymentIntentID sql.NullString `json:"stripe_payment_intent_id"`
SyncStatus string `json:"sync_status"`
Livemode sql.NullBool `json:"livemode"`
}
// livemode is the invoice object's own flag inside the stored payload
// (stripe-environment-stamp D1): the payment is projected from the
// invoice, and its payment intent lives in the same Stripe environment.
func (q *Queries) InsertPaymentMapping(ctx context.Context, arg InsertPaymentMappingParams) (PaymentMapping, error) {
row := q.db.QueryRowContext(ctx, insertPaymentMapping, arg.PaymentID, arg.StripePaymentIntentID, arg.SyncStatus)
row := q.db.QueryRowContext(ctx, insertPaymentMapping,
arg.PaymentID,
arg.StripePaymentIntentID,
arg.SyncStatus,
arg.Livemode,
)
var i PaymentMapping
err := row.Scan(
&i.PaymentID,
@@ -70,6 +93,38 @@ func (q *Queries) InsertPaymentMapping(ctx context.Context, arg InsertPaymentMap
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
)
return i, err
}
const listPaymentIDsOutsideMode = `-- name: ListPaymentIDsOutsideMode :many
SELECT payment_id FROM stripe.payment_mappings
WHERE livemode IS NOT NULL AND livemode <> $1::boolean
`
// The payments the operator view excludes under the current key
// (stripe-environment-stamp D7). A NULL livemode is unverified and shows
// under either key, so it is not in this list.
func (q *Queries) ListPaymentIDsOutsideMode(ctx context.Context, keyLivemode bool) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listPaymentIDsOutsideMode, keyLivemode)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var payment_id string
if err := rows.Scan(&payment_id); err != nil {
return nil, err
}
items = append(items, payment_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
@@ -14,7 +14,7 @@ import (
)
const getPaymentMethodMappingByPaymentMethodID = `-- name: GetPaymentMethodMappingByPaymentMethodID :one
SELECT payment_method_id, stripe_payment_method_id, sync_status, created_at, updated_at FROM stripe.payment_method_mappings
SELECT payment_method_id, stripe_payment_method_id, sync_status, created_at, updated_at, livemode FROM stripe.payment_method_mappings
WHERE payment_method_id = $1
`
@@ -27,12 +27,13 @@ func (q *Queries) GetPaymentMethodMappingByPaymentMethodID(ctx context.Context,
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
)
return i, err
}
const getPaymentMethodMappingByStripeID = `-- name: GetPaymentMethodMappingByStripeID :one
SELECT payment_method_id, stripe_payment_method_id, sync_status, created_at, updated_at FROM stripe.payment_method_mappings
SELECT payment_method_id, stripe_payment_method_id, sync_status, created_at, updated_at, livemode FROM stripe.payment_method_mappings
WHERE stripe_payment_method_id = $1
`
@@ -45,28 +46,40 @@ func (q *Queries) GetPaymentMethodMappingByStripeID(ctx context.Context, stripeP
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
)
return i, err
}
const upsertPaymentMethodMapping = `-- name: UpsertPaymentMethodMapping :one
INSERT INTO stripe.payment_method_mappings (payment_method_id, stripe_payment_method_id, sync_status)
VALUES ($1, $2, $3)
INSERT INTO stripe.payment_method_mappings (payment_method_id, stripe_payment_method_id, sync_status, livemode)
VALUES ($1, $2, $3, $4)
ON CONFLICT (payment_method_id) DO UPDATE
SET stripe_payment_method_id = EXCLUDED.stripe_payment_method_id,
sync_status = EXCLUDED.sync_status,
livemode = COALESCE(EXCLUDED.livemode, payment_method_mappings.livemode),
updated_at = NOW()
RETURNING payment_method_id, stripe_payment_method_id, sync_status, created_at, updated_at
RETURNING payment_method_id, stripe_payment_method_id, sync_status, created_at, updated_at, livemode
`
type UpsertPaymentMethodMappingParams struct {
PaymentMethodID string `json:"payment_method_id"`
StripePaymentMethodID sql.NullString `json:"stripe_payment_method_id"`
SyncStatus string `json:"sync_status"`
Livemode sql.NullBool `json:"livemode"`
}
// livemode comes from the payment method object's own flag inside the
// stored webhook payload (stripe-environment-stamp D1), not from the
// event envelope. The conflict branch COALESCEs it so a caller with no
// flag to offer passes NULL without erasing a known environment.
func (q *Queries) UpsertPaymentMethodMapping(ctx context.Context, arg UpsertPaymentMethodMappingParams) (PaymentMethodMapping, error) {
row := q.db.QueryRowContext(ctx, upsertPaymentMethodMapping, arg.PaymentMethodID, arg.StripePaymentMethodID, arg.SyncStatus)
row := q.db.QueryRowContext(ctx, upsertPaymentMethodMapping,
arg.PaymentMethodID,
arg.StripePaymentMethodID,
arg.SyncStatus,
arg.Livemode,
)
var i PaymentMethodMapping
err := row.Scan(
&i.PaymentMethodID,
@@ -74,6 +87,7 @@ func (q *Queries) UpsertPaymentMethodMapping(ctx context.Context, arg UpsertPaym
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
)
return i, err
}
@@ -27,8 +27,22 @@ func (q *Queries) AnyMappedPrice(ctx context.Context) (bool, error) {
return exists, err
}
const clearPriceMappingVerifiedAt = `-- name: ClearPriceMappingVerifiedAt :exec
UPDATE stripe.price_mappings
SET verified_at = NULL
WHERE verified_at IS NOT NULL
`
// The API key changed, so no price id is verified any more (design D3's
// boot step). The recorded livemode stays: it is what the writer saw, and
// the check is about to confirm or contradict it.
func (q *Queries) ClearPriceMappingVerifiedAt(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, clearPriceMappingVerifiedAt)
return err
}
const getPriceMappingByPriceID = `-- name: GetPriceMappingByPriceID :one
SELECT price_id, stripe_price_id, sync_status, created_at, updated_at FROM stripe.price_mappings
SELECT price_id, stripe_price_id, sync_status, created_at, updated_at, livemode, verified_at FROM stripe.price_mappings
WHERE price_id = $1
`
@@ -41,12 +55,14 @@ func (q *Queries) GetPriceMappingByPriceID(ctx context.Context, priceID string)
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
&i.VerifiedAt,
)
return i, err
}
const getPriceMappingByStripeID = `-- name: GetPriceMappingByStripeID :one
SELECT price_id, stripe_price_id, sync_status, created_at, updated_at FROM stripe.price_mappings
SELECT price_id, stripe_price_id, sync_status, created_at, updated_at, livemode, verified_at FROM stripe.price_mappings
WHERE stripe_price_id = $1
`
@@ -59,12 +75,14 @@ func (q *Queries) GetPriceMappingByStripeID(ctx context.Context, stripePriceID s
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
&i.VerifiedAt,
)
return i, err
}
const listPriceMappingsByPriceIDs = `-- name: ListPriceMappingsByPriceIDs :many
SELECT price_id, stripe_price_id, sync_status, created_at, updated_at FROM stripe.price_mappings
SELECT price_id, stripe_price_id, sync_status, created_at, updated_at, livemode, verified_at FROM stripe.price_mappings
WHERE price_id = ANY($1::uuid[])
`
@@ -87,6 +105,55 @@ func (q *Queries) ListPriceMappingsByPriceIDs(ctx context.Context, priceIds []st
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
&i.VerifiedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listPriceMappingsForCheck = `-- name: ListPriceMappingsForCheck :many
SELECT price_id, stripe_price_id, livemode, sync_status, verified_at
FROM stripe.price_mappings
WHERE stripe_price_id IS NOT NULL
ORDER BY price_id
`
type ListPriceMappingsForCheckRow struct {
PriceID string `json:"price_id"`
StripePriceID sql.NullString `json:"stripe_price_id"`
Livemode sql.NullBool `json:"livemode"`
SyncStatus string `json:"sync_status"`
VerifiedAt sql.NullTime `json:"verified_at"`
}
// The rows the environment check reads back (stripe-environment-stamp
// D3): every mapping holding a Stripe id, with the state the check
// compares against what Stripe answers.
func (q *Queries) ListPriceMappingsForCheck(ctx context.Context) ([]ListPriceMappingsForCheckRow, error) {
rows, err := q.db.QueryContext(ctx, listPriceMappingsForCheck)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListPriceMappingsForCheckRow{}
for rows.Next() {
var i ListPriceMappingsForCheckRow
if err := rows.Scan(
&i.PriceID,
&i.StripePriceID,
&i.Livemode,
&i.SyncStatus,
&i.VerifiedAt,
); err != nil {
return nil, err
}
@@ -112,24 +179,73 @@ func (q *Queries) MarkPriceMappingDeleted(ctx context.Context, priceID string) e
return err
}
const markPriceMappingStale = `-- name: MarkPriceMappingStale :exec
UPDATE stripe.price_mappings
SET sync_status = 'stale', verified_at = NOW(), updated_at = NOW()
WHERE price_id = $1
`
// Stripe answered resource_missing under the current key. The recorded
// livemode is left as it was, because the object still exists in the
// environment that made it.
func (q *Queries) MarkPriceMappingStale(ctx context.Context, priceID string) error {
_, err := q.db.ExecContext(ctx, markPriceMappingStale, priceID)
return err
}
const markPriceMappingVerified = `-- name: MarkPriceMappingVerified :exec
UPDATE stripe.price_mappings
SET livemode = $1::boolean,
verified_at = NOW(),
sync_status = CASE WHEN sync_status = 'stale' THEN 'synced' ELSE sync_status END,
updated_at = NOW()
WHERE price_id = $2
`
type MarkPriceMappingVerifiedParams struct {
Livemode bool `json:"livemode"`
PriceID string `json:"price_id"`
}
// The id resolved under the current key: record the environment the
// object reports and when the read-back confirmed it. A row marked stale
// by an earlier run returns to synced, because the id is reachable again.
func (q *Queries) MarkPriceMappingVerified(ctx context.Context, arg MarkPriceMappingVerifiedParams) error {
_, err := q.db.ExecContext(ctx, markPriceMappingVerified, arg.Livemode, arg.PriceID)
return err
}
const upsertPriceMapping = `-- name: UpsertPriceMapping :one
INSERT INTO stripe.price_mappings (price_id, stripe_price_id, sync_status)
VALUES ($1, $2, $3)
INSERT INTO stripe.price_mappings (price_id, stripe_price_id, sync_status, livemode)
VALUES ($1, $2, $3, $4)
ON CONFLICT (price_id) DO UPDATE
SET stripe_price_id = EXCLUDED.stripe_price_id,
sync_status = EXCLUDED.sync_status,
livemode = COALESCE(EXCLUDED.livemode, price_mappings.livemode),
updated_at = NOW()
RETURNING price_id, stripe_price_id, sync_status, created_at, updated_at
RETURNING price_id, stripe_price_id, sync_status, created_at, updated_at, livemode, verified_at
`
type UpsertPriceMappingParams struct {
PriceID string `json:"price_id"`
StripePriceID sql.NullString `json:"stripe_price_id"`
SyncStatus string `json:"sync_status"`
Livemode sql.NullBool `json:"livemode"`
}
// livemode records the Stripe environment the id was created in
// (stripe-environment-stamp D1), taken from the price object Stripe
// returned, never from the API key. On the conflict branch it is
// COALESCEd with the stored value: a caller that has no flag to offer
// passes NULL, and NULL must not erase an environment the console already
// knows.
func (q *Queries) UpsertPriceMapping(ctx context.Context, arg UpsertPriceMappingParams) (PriceMapping, error) {
row := q.db.QueryRowContext(ctx, upsertPriceMapping, arg.PriceID, arg.StripePriceID, arg.SyncStatus)
row := q.db.QueryRowContext(ctx, upsertPriceMapping,
arg.PriceID,
arg.StripePriceID,
arg.SyncStatus,
arg.Livemode,
)
var i PriceMapping
err := row.Scan(
&i.PriceID,
@@ -137,6 +253,8 @@ func (q *Queries) UpsertPriceMapping(ctx context.Context, arg UpsertPriceMapping
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
&i.VerifiedAt,
)
return i, err
}
@@ -13,8 +13,22 @@ import (
"database/sql"
)
const clearProductMappingVerifiedAt = `-- name: ClearProductMappingVerifiedAt :exec
UPDATE stripe.product_mappings
SET verified_at = NULL
WHERE verified_at IS NOT NULL
`
// The API key changed, so no product id is verified any more (design D3's
// boot step). The recorded livemode stays: it is what the writer saw, and
// the check is about to confirm or contradict it.
func (q *Queries) ClearProductMappingVerifiedAt(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, clearProductMappingVerifiedAt)
return err
}
const getProductMappingByProductID = `-- name: GetProductMappingByProductID :one
SELECT product_id, stripe_product_id, sync_status, created_at, updated_at FROM stripe.product_mappings
SELECT product_id, stripe_product_id, sync_status, created_at, updated_at, livemode, verified_at FROM stripe.product_mappings
WHERE product_id = $1
`
@@ -27,12 +41,14 @@ func (q *Queries) GetProductMappingByProductID(ctx context.Context, productID st
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
&i.VerifiedAt,
)
return i, err
}
const getProductMappingByStripeID = `-- name: GetProductMappingByStripeID :one
SELECT product_id, stripe_product_id, sync_status, created_at, updated_at FROM stripe.product_mappings
SELECT product_id, stripe_product_id, sync_status, created_at, updated_at, livemode, verified_at FROM stripe.product_mappings
WHERE stripe_product_id = $1
`
@@ -45,10 +61,59 @@ func (q *Queries) GetProductMappingByStripeID(ctx context.Context, stripeProduct
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
&i.VerifiedAt,
)
return i, err
}
const listProductMappingsForCheck = `-- name: ListProductMappingsForCheck :many
SELECT product_id, stripe_product_id, livemode, sync_status, verified_at
FROM stripe.product_mappings
WHERE stripe_product_id IS NOT NULL
ORDER BY product_id
`
type ListProductMappingsForCheckRow struct {
ProductID string `json:"product_id"`
StripeProductID sql.NullString `json:"stripe_product_id"`
Livemode sql.NullBool `json:"livemode"`
SyncStatus string `json:"sync_status"`
VerifiedAt sql.NullTime `json:"verified_at"`
}
// The rows the environment check reads back (stripe-environment-stamp
// D3): every mapping holding a Stripe id, with the state the check
// compares against what Stripe answers.
func (q *Queries) ListProductMappingsForCheck(ctx context.Context) ([]ListProductMappingsForCheckRow, error) {
rows, err := q.db.QueryContext(ctx, listProductMappingsForCheck)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListProductMappingsForCheckRow{}
for rows.Next() {
var i ListProductMappingsForCheckRow
if err := rows.Scan(
&i.ProductID,
&i.StripeProductID,
&i.Livemode,
&i.SyncStatus,
&i.VerifiedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markProductMappingDeleted = `-- name: MarkProductMappingDeleted :exec
UPDATE stripe.product_mappings
SET sync_status = 'deleted', updated_at = NOW()
@@ -60,24 +125,73 @@ func (q *Queries) MarkProductMappingDeleted(ctx context.Context, productID strin
return err
}
const markProductMappingStale = `-- name: MarkProductMappingStale :exec
UPDATE stripe.product_mappings
SET sync_status = 'stale', verified_at = NOW(), updated_at = NOW()
WHERE product_id = $1
`
// Stripe answered resource_missing under the current key. The recorded
// livemode is left as it was, because the object still exists in the
// environment that made it.
func (q *Queries) MarkProductMappingStale(ctx context.Context, productID string) error {
_, err := q.db.ExecContext(ctx, markProductMappingStale, productID)
return err
}
const markProductMappingVerified = `-- name: MarkProductMappingVerified :exec
UPDATE stripe.product_mappings
SET livemode = $1::boolean,
verified_at = NOW(),
sync_status = CASE WHEN sync_status = 'stale' THEN 'synced' ELSE sync_status END,
updated_at = NOW()
WHERE product_id = $2
`
type MarkProductMappingVerifiedParams struct {
Livemode bool `json:"livemode"`
ProductID string `json:"product_id"`
}
// The id resolved under the current key: record the environment the
// object reports and when the read-back confirmed it. A row marked stale
// by an earlier run returns to synced, because the id is reachable again.
func (q *Queries) MarkProductMappingVerified(ctx context.Context, arg MarkProductMappingVerifiedParams) error {
_, err := q.db.ExecContext(ctx, markProductMappingVerified, arg.Livemode, arg.ProductID)
return err
}
const upsertProductMapping = `-- name: UpsertProductMapping :one
INSERT INTO stripe.product_mappings (product_id, stripe_product_id, sync_status)
VALUES ($1, $2, $3)
INSERT INTO stripe.product_mappings (product_id, stripe_product_id, sync_status, livemode)
VALUES ($1, $2, $3, $4)
ON CONFLICT (product_id) DO UPDATE
SET stripe_product_id = EXCLUDED.stripe_product_id,
sync_status = EXCLUDED.sync_status,
livemode = COALESCE(EXCLUDED.livemode, product_mappings.livemode),
updated_at = NOW()
RETURNING product_id, stripe_product_id, sync_status, created_at, updated_at
RETURNING product_id, stripe_product_id, sync_status, created_at, updated_at, livemode, verified_at
`
type UpsertProductMappingParams struct {
ProductID string `json:"product_id"`
StripeProductID sql.NullString `json:"stripe_product_id"`
SyncStatus string `json:"sync_status"`
Livemode sql.NullBool `json:"livemode"`
}
// livemode records the Stripe environment the id was created in
// (stripe-environment-stamp D1), taken from the product object Stripe
// returned, never from the API key. On the conflict branch it is
// COALESCEd with the stored value: a caller that has no flag to offer
// passes NULL, and NULL must not erase an environment the console already
// knows.
func (q *Queries) UpsertProductMapping(ctx context.Context, arg UpsertProductMappingParams) (ProductMapping, error) {
row := q.db.QueryRowContext(ctx, upsertProductMapping, arg.ProductID, arg.StripeProductID, arg.SyncStatus)
row := q.db.QueryRowContext(ctx, upsertProductMapping,
arg.ProductID,
arg.StripeProductID,
arg.SyncStatus,
arg.Livemode,
)
var i ProductMapping
err := row.Scan(
&i.ProductID,
@@ -85,6 +199,8 @@ func (q *Queries) UpsertProductMapping(ctx context.Context, arg UpsertProductMap
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
&i.VerifiedAt,
)
return i, err
}
@@ -15,6 +15,18 @@ import (
type Querier interface {
// Setup-checklist existence probe: paired in Go with billing's AnyActivePrice.
AnyMappedPrice(ctx context.Context) (bool, error)
// The API key changed, so no price id is verified any more (design D3's
// boot step). The recorded livemode stays: it is what the writer saw, and
// the check is about to confirm or contradict it.
ClearPriceMappingVerifiedAt(ctx context.Context) error
// The API key changed, so no product id is verified any more (design D3's
// boot step). The recorded livemode stays: it is what the writer saw, and
// the check is about to confirm or contradict it.
ClearProductMappingVerifiedAt(ctx context.Context) error
CountCustomerMappingsOutsideMode(ctx context.Context, keyLivemode bool) (int64, error)
CountInvoiceMappingsOutsideMode(ctx context.Context, keyLivemode bool) (int64, error)
CountPaymentMappingsOutsideMode(ctx context.Context, keyLivemode bool) (int64, error)
CountSubscriptionMappingsOutsideMode(ctx context.Context, keyLivemode bool) (int64, error)
GetCustomerMappingByBillingAccountID(ctx context.Context, billingAccountID string) (CustomerMapping, error)
GetCustomerMappingByStripeCustomerID(ctx context.Context, stripeCustomerID sql.NullString) (CustomerMapping, error)
GetInvoiceMappingByInvoiceID(ctx context.Context, invoiceID string) (InvoiceMapping, error)
@@ -35,8 +47,24 @@ type Querier interface {
// D5), carried here as received from the invoice.finalized payload's
// `number` field -- an external reference, never the invoice's identity
// (that is core.invoices.invoice_number, the platform-assigned one).
// livemode is the invoice object's own flag inside the stored payload
// (stripe-environment-stamp D1), not the event envelope's.
InsertInvoiceMapping(ctx context.Context, arg InsertInvoiceMappingParams) (InvoiceMapping, error)
// livemode is the invoice object's own flag inside the stored payload
// (stripe-environment-stamp D1): the payment is projected from the
// invoice, and its payment intent lives in the same Stripe environment.
InsertPaymentMapping(ctx context.Context, arg InsertPaymentMappingParams) (PaymentMapping, error)
// The billing accounts the operator views exclude under the current key
// (stripe-environment-stamp D7): a mapping whose recorded environment is
// set and differs from the key's. A NULL livemode is unverified and shows
// under either key, so it is not in this list.
ListBillingAccountIDsOutsideMode(ctx context.Context, keyLivemode bool) ([]string, error)
// The invoices the operator view excludes under the current key
// (stripe-environment-stamp D7), resolved here and handed to
// ListInvoicesPage the same way the Stripe-number search feeds it ids. A
// NULL livemode is unverified and shows under either key, so it is not in
// this list.
ListInvoiceIDsOutsideMode(ctx context.Context, keyLivemode bool) ([]string, error)
// Resolves invoice ids whose Stripe number matches a case-insensitive
// substring, for the operator invoices search (invoice-numbers D6):
// internal/billing cannot join the stripe schema (see invoices.sql's
@@ -44,20 +72,83 @@ type Querier interface {
// passes the ids into ListInvoicesPage as a pre-resolved array, mirroring
// matchingOrgIDs.
ListInvoiceMappingsByStripeNumberSubstring(ctx context.Context, dollar_1 sql.NullString) ([]string, error)
// The payments the operator view excludes under the current key
// (stripe-environment-stamp D7). A NULL livemode is unverified and shows
// under either key, so it is not in this list.
ListPaymentIDsOutsideMode(ctx context.Context, keyLivemode bool) ([]string, error)
// Batch reader for the products list's per-page verdict (product-management
// "The verdict per page, from batches, never cached", design D4): the
// Stripe mapping state for the page's active prices, one query instead of
// one per product.
ListPriceMappingsByPriceIDs(ctx context.Context, priceIds []string) ([]PriceMapping, error)
// The rows the environment check reads back (stripe-environment-stamp
// D3): every mapping holding a Stripe id, with the state the check
// compares against what Stripe answers.
ListPriceMappingsForCheck(ctx context.Context) ([]ListPriceMappingsForCheckRow, error)
// The rows the environment check reads back (stripe-environment-stamp
// D3): every mapping holding a Stripe id, with the state the check
// compares against what Stripe answers.
ListProductMappingsForCheck(ctx context.Context) ([]ListProductMappingsForCheckRow, error)
// The subscriptions the operator views exclude under the current key
// (stripe-environment-stamp D7). A NULL livemode is unverified and shows
// under either key, so it is not in this list.
ListSubscriptionIDsOutsideMode(ctx context.Context, keyLivemode bool) ([]string, error)
MarkPriceMappingDeleted(ctx context.Context, priceID string) error
// Stripe answered resource_missing under the current key. The recorded
// livemode is left as it was, because the object still exists in the
// environment that made it.
MarkPriceMappingStale(ctx context.Context, priceID string) error
// The id resolved under the current key: record the environment the
// object reports and when the read-back confirmed it. A row marked stale
// by an earlier run returns to synced, because the id is reachable again.
MarkPriceMappingVerified(ctx context.Context, arg MarkPriceMappingVerifiedParams) error
MarkProductMappingDeleted(ctx context.Context, productID string) error
// Stripe answered resource_missing under the current key. The recorded
// livemode is left as it was, because the object still exists in the
// environment that made it.
MarkProductMappingStale(ctx context.Context, productID string) error
// The id resolved under the current key: record the environment the
// object reports and when the read-back confirmed it. A row marked stale
// by an earlier run returns to synced, because the id is reachable again.
MarkProductMappingVerified(ctx context.Context, arg MarkProductMappingVerifiedParams) error
UpdateCustomerMappingSyncStatus(ctx context.Context, arg UpdateCustomerMappingSyncStatusParams) error
UpdateSubscriptionMappingSyncStatus(ctx context.Context, arg UpdateSubscriptionMappingSyncStatusParams) error
// livemode records the Stripe environment the id was created in
// (stripe-environment-stamp D1), taken from the customer object Stripe
// returned, never from the API key. On the conflict branch it is
// COALESCEd with the stored value: a caller that has no flag to offer
// passes NULL, and NULL must not erase an environment the console already
// knows.
UpsertCustomerMapping(ctx context.Context, arg UpsertCustomerMappingParams) (CustomerMapping, error)
// livemode comes from the payment method object's own flag inside the
// stored webhook payload (stripe-environment-stamp D1), not from the
// event envelope. The conflict branch COALESCEs it so a caller with no
// flag to offer passes NULL without erasing a known environment.
UpsertPaymentMethodMapping(ctx context.Context, arg UpsertPaymentMethodMappingParams) (PaymentMethodMapping, error)
// livemode records the Stripe environment the id was created in
// (stripe-environment-stamp D1), taken from the price object Stripe
// returned, never from the API key. On the conflict branch it is
// COALESCEd with the stored value: a caller that has no flag to offer
// passes NULL, and NULL must not erase an environment the console already
// knows.
UpsertPriceMapping(ctx context.Context, arg UpsertPriceMappingParams) (PriceMapping, error)
// livemode records the Stripe environment the id was created in
// (stripe-environment-stamp D1), taken from the product object Stripe
// returned, never from the API key. On the conflict branch it is
// COALESCEd with the stored value: a caller that has no flag to offer
// passes NULL, and NULL must not erase an environment the console already
// knows.
UpsertProductMapping(ctx context.Context, arg UpsertProductMappingParams) (ProductMapping, error)
// livemode is the parent subscription's flag: a Stripe subscription item
// carries none of its own (stripe-environment-stamp D1, D4). The conflict
// branch COALESCEs it so a caller with no flag to offer passes NULL
// without erasing a known environment.
UpsertSubscriptionItemMapping(ctx context.Context, arg UpsertSubscriptionItemMappingParams) (SubscriptionItemMapping, error)
// livemode is the refetched subscription's own flag
// (stripe-environment-stamp D4): the console never creates a subscription,
// so the stamp rides the read-back the reconcile already performs. The
// conflict branch COALESCEs it so a caller with no flag to offer passes
// NULL without erasing a known environment.
UpsertSubscriptionMapping(ctx context.Context, arg UpsertSubscriptionMappingParams) (SubscriptionMapping, error)
}
@@ -1,9 +1,16 @@
-- name: UpsertCustomerMapping :one
INSERT INTO stripe.customer_mappings (billing_account_id, stripe_customer_id, sync_status)
VALUES ($1, $2, $3)
-- livemode records the Stripe environment the id was created in
-- (stripe-environment-stamp D1), taken from the customer object Stripe
-- returned, never from the API key. On the conflict branch it is
-- COALESCEd with the stored value: a caller that has no flag to offer
-- passes NULL, and NULL must not erase an environment the console already
-- knows.
INSERT INTO stripe.customer_mappings (billing_account_id, stripe_customer_id, sync_status, livemode)
VALUES ($1, $2, $3, $4)
ON CONFLICT (billing_account_id) DO UPDATE
SET stripe_customer_id = EXCLUDED.stripe_customer_id,
sync_status = EXCLUDED.sync_status,
livemode = COALESCE(EXCLUDED.livemode, customer_mappings.livemode),
updated_at = NOW()
RETURNING *;
@@ -19,3 +26,15 @@ WHERE stripe_customer_id = $1;
UPDATE stripe.customer_mappings
SET sync_status = $2, updated_at = NOW()
WHERE billing_account_id = $1;
-- name: ListBillingAccountIDsOutsideMode :many
-- The billing accounts the operator views exclude under the current key
-- (stripe-environment-stamp D7): a mapping whose recorded environment is
-- set and differs from the key's. A NULL livemode is unverified and shows
-- under either key, so it is not in this list.
SELECT billing_account_id FROM stripe.customer_mappings
WHERE livemode IS NOT NULL AND livemode <> sqlc.arg(key_livemode)::boolean;
-- name: CountCustomerMappingsOutsideMode :one
SELECT COUNT(*) FROM stripe.customer_mappings
WHERE livemode IS NOT NULL AND livemode <> sqlc.arg(key_livemode)::boolean;
@@ -3,8 +3,10 @@
-- D5), carried here as received from the invoice.finalized payload's
-- `number` field -- an external reference, never the invoice's identity
-- (that is core.invoices.invoice_number, the platform-assigned one).
INSERT INTO stripe.invoice_mappings (invoice_id, stripe_invoice_id, stripe_invoice_number, sync_status)
VALUES ($1, $2, $3, $4)
-- livemode is the invoice object's own flag inside the stored payload
-- (stripe-environment-stamp D1), not the event envelope's.
INSERT INTO stripe.invoice_mappings (invoice_id, stripe_invoice_id, stripe_invoice_number, sync_status, livemode)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: GetInvoiceMappingByStripeID :one
@@ -24,3 +26,16 @@ WHERE invoice_id = $1;
-- matchingOrgIDs.
SELECT invoice_id FROM stripe.invoice_mappings
WHERE stripe_invoice_number ILIKE '%' || $1 || '%';
-- name: ListInvoiceIDsOutsideMode :many
-- The invoices the operator view excludes under the current key
-- (stripe-environment-stamp D7), resolved here and handed to
-- ListInvoicesPage the same way the Stripe-number search feeds it ids. A
-- NULL livemode is unverified and shows under either key, so it is not in
-- this list.
SELECT invoice_id FROM stripe.invoice_mappings
WHERE livemode IS NOT NULL AND livemode <> sqlc.arg(key_livemode)::boolean;
-- name: CountInvoiceMappingsOutsideMode :one
SELECT COUNT(*) FROM stripe.invoice_mappings
WHERE livemode IS NOT NULL AND livemode <> sqlc.arg(key_livemode)::boolean;
@@ -1,6 +1,9 @@
-- name: InsertPaymentMapping :one
INSERT INTO stripe.payment_mappings (payment_id, stripe_payment_intent_id, sync_status)
VALUES ($1, $2, $3)
-- livemode is the invoice object's own flag inside the stored payload
-- (stripe-environment-stamp D1): the payment is projected from the
-- invoice, and its payment intent lives in the same Stripe environment.
INSERT INTO stripe.payment_mappings (payment_id, stripe_payment_intent_id, sync_status, livemode)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: GetPaymentMappingByStripePaymentIntentID :one
@@ -10,3 +13,14 @@ WHERE stripe_payment_intent_id = $1;
-- name: GetPaymentMappingByPaymentID :one
SELECT * FROM stripe.payment_mappings
WHERE payment_id = $1;
-- name: ListPaymentIDsOutsideMode :many
-- The payments the operator view excludes under the current key
-- (stripe-environment-stamp D7). A NULL livemode is unverified and shows
-- under either key, so it is not in this list.
SELECT payment_id FROM stripe.payment_mappings
WHERE livemode IS NOT NULL AND livemode <> sqlc.arg(key_livemode)::boolean;
-- name: CountPaymentMappingsOutsideMode :one
SELECT COUNT(*) FROM stripe.payment_mappings
WHERE livemode IS NOT NULL AND livemode <> sqlc.arg(key_livemode)::boolean;
@@ -1,9 +1,14 @@
-- name: UpsertPaymentMethodMapping :one
INSERT INTO stripe.payment_method_mappings (payment_method_id, stripe_payment_method_id, sync_status)
VALUES ($1, $2, $3)
-- livemode comes from the payment method object's own flag inside the
-- stored webhook payload (stripe-environment-stamp D1), not from the
-- event envelope. The conflict branch COALESCEs it so a caller with no
-- flag to offer passes NULL without erasing a known environment.
INSERT INTO stripe.payment_method_mappings (payment_method_id, stripe_payment_method_id, sync_status, livemode)
VALUES ($1, $2, $3, $4)
ON CONFLICT (payment_method_id) DO UPDATE
SET stripe_payment_method_id = EXCLUDED.stripe_payment_method_id,
sync_status = EXCLUDED.sync_status,
livemode = COALESCE(EXCLUDED.livemode, payment_method_mappings.livemode),
updated_at = NOW()
RETURNING *;
@@ -1,9 +1,16 @@
-- name: UpsertPriceMapping :one
INSERT INTO stripe.price_mappings (price_id, stripe_price_id, sync_status)
VALUES ($1, $2, $3)
-- livemode records the Stripe environment the id was created in
-- (stripe-environment-stamp D1), taken from the price object Stripe
-- returned, never from the API key. On the conflict branch it is
-- COALESCEd with the stored value: a caller that has no flag to offer
-- passes NULL, and NULL must not erase an environment the console already
-- knows.
INSERT INTO stripe.price_mappings (price_id, stripe_price_id, sync_status, livemode)
VALUES ($1, $2, $3, $4)
ON CONFLICT (price_id) DO UPDATE
SET stripe_price_id = EXCLUDED.stripe_price_id,
sync_status = EXCLUDED.sync_status,
livemode = COALESCE(EXCLUDED.livemode, price_mappings.livemode),
updated_at = NOW()
RETURNING *;
@@ -31,3 +38,39 @@ WHERE price_id = $1;
-- name: AnyMappedPrice :one
-- Setup-checklist existence probe: paired in Go with billing's AnyActivePrice.
SELECT EXISTS(SELECT 1 FROM stripe.price_mappings WHERE stripe_price_id IS NOT NULL);
-- name: ListPriceMappingsForCheck :many
-- The rows the environment check reads back (stripe-environment-stamp
-- D3): every mapping holding a Stripe id, with the state the check
-- compares against what Stripe answers.
SELECT price_id, stripe_price_id, livemode, sync_status, verified_at
FROM stripe.price_mappings
WHERE stripe_price_id IS NOT NULL
ORDER BY price_id;
-- name: MarkPriceMappingVerified :exec
-- The id resolved under the current key: record the environment the
-- object reports and when the read-back confirmed it. A row marked stale
-- by an earlier run returns to synced, because the id is reachable again.
UPDATE stripe.price_mappings
SET livemode = sqlc.arg(livemode)::boolean,
verified_at = NOW(),
sync_status = CASE WHEN sync_status = 'stale' THEN 'synced' ELSE sync_status END,
updated_at = NOW()
WHERE price_id = sqlc.arg(price_id);
-- name: MarkPriceMappingStale :exec
-- Stripe answered resource_missing under the current key. The recorded
-- livemode is left as it was, because the object still exists in the
-- environment that made it.
UPDATE stripe.price_mappings
SET sync_status = 'stale', verified_at = NOW(), updated_at = NOW()
WHERE price_id = $1;
-- name: ClearPriceMappingVerifiedAt :exec
-- The API key changed, so no price id is verified any more (design D3's
-- boot step). The recorded livemode stays: it is what the writer saw, and
-- the check is about to confirm or contradict it.
UPDATE stripe.price_mappings
SET verified_at = NULL
WHERE verified_at IS NOT NULL;
@@ -1,9 +1,16 @@
-- name: UpsertProductMapping :one
INSERT INTO stripe.product_mappings (product_id, stripe_product_id, sync_status)
VALUES ($1, $2, $3)
-- livemode records the Stripe environment the id was created in
-- (stripe-environment-stamp D1), taken from the product object Stripe
-- returned, never from the API key. On the conflict branch it is
-- COALESCEd with the stored value: a caller that has no flag to offer
-- passes NULL, and NULL must not erase an environment the console already
-- knows.
INSERT INTO stripe.product_mappings (product_id, stripe_product_id, sync_status, livemode)
VALUES ($1, $2, $3, $4)
ON CONFLICT (product_id) DO UPDATE
SET stripe_product_id = EXCLUDED.stripe_product_id,
sync_status = EXCLUDED.sync_status,
livemode = COALESCE(EXCLUDED.livemode, product_mappings.livemode),
updated_at = NOW()
RETURNING *;
@@ -19,3 +26,39 @@ WHERE stripe_product_id = $1;
UPDATE stripe.product_mappings
SET sync_status = 'deleted', updated_at = NOW()
WHERE product_id = $1;
-- name: ListProductMappingsForCheck :many
-- The rows the environment check reads back (stripe-environment-stamp
-- D3): every mapping holding a Stripe id, with the state the check
-- compares against what Stripe answers.
SELECT product_id, stripe_product_id, livemode, sync_status, verified_at
FROM stripe.product_mappings
WHERE stripe_product_id IS NOT NULL
ORDER BY product_id;
-- name: MarkProductMappingVerified :exec
-- The id resolved under the current key: record the environment the
-- object reports and when the read-back confirmed it. A row marked stale
-- by an earlier run returns to synced, because the id is reachable again.
UPDATE stripe.product_mappings
SET livemode = sqlc.arg(livemode)::boolean,
verified_at = NOW(),
sync_status = CASE WHEN sync_status = 'stale' THEN 'synced' ELSE sync_status END,
updated_at = NOW()
WHERE product_id = sqlc.arg(product_id);
-- name: MarkProductMappingStale :exec
-- Stripe answered resource_missing under the current key. The recorded
-- livemode is left as it was, because the object still exists in the
-- environment that made it.
UPDATE stripe.product_mappings
SET sync_status = 'stale', verified_at = NOW(), updated_at = NOW()
WHERE product_id = $1;
-- name: ClearProductMappingVerifiedAt :exec
-- The API key changed, so no product id is verified any more (design D3's
-- boot step). The recorded livemode stays: it is what the writer saw, and
-- the check is about to confirm or contradict it.
UPDATE stripe.product_mappings
SET verified_at = NULL
WHERE verified_at IS NOT NULL;
@@ -1,9 +1,14 @@
-- name: UpsertSubscriptionItemMapping :one
INSERT INTO stripe.subscription_item_mappings (subscription_item_id, stripe_subscription_item_id, sync_status)
VALUES ($1, $2, $3)
-- livemode is the parent subscription's flag: a Stripe subscription item
-- carries none of its own (stripe-environment-stamp D1, D4). The conflict
-- branch COALESCEs it so a caller with no flag to offer passes NULL
-- without erasing a known environment.
INSERT INTO stripe.subscription_item_mappings (subscription_item_id, stripe_subscription_item_id, sync_status, livemode)
VALUES ($1, $2, $3, $4)
ON CONFLICT (subscription_item_id) DO UPDATE
SET stripe_subscription_item_id = EXCLUDED.stripe_subscription_item_id,
sync_status = EXCLUDED.sync_status,
livemode = COALESCE(EXCLUDED.livemode, subscription_item_mappings.livemode),
updated_at = NOW()
RETURNING *;
@@ -1,9 +1,15 @@
-- name: UpsertSubscriptionMapping :one
INSERT INTO stripe.subscription_mappings (subscription_id, stripe_subscription_id, sync_status)
VALUES ($1, $2, $3)
-- livemode is the refetched subscription's own flag
-- (stripe-environment-stamp D4): the console never creates a subscription,
-- so the stamp rides the read-back the reconcile already performs. The
-- conflict branch COALESCEs it so a caller with no flag to offer passes
-- NULL without erasing a known environment.
INSERT INTO stripe.subscription_mappings (subscription_id, stripe_subscription_id, sync_status, livemode)
VALUES ($1, $2, $3, $4)
ON CONFLICT (subscription_id) DO UPDATE
SET stripe_subscription_id = EXCLUDED.stripe_subscription_id,
sync_status = EXCLUDED.sync_status,
livemode = COALESCE(EXCLUDED.livemode, subscription_mappings.livemode),
updated_at = NOW()
RETURNING *;
@@ -19,3 +25,14 @@ WHERE stripe_subscription_id = $1;
UPDATE stripe.subscription_mappings
SET sync_status = $2, updated_at = NOW()
WHERE subscription_id = $1;
-- name: ListSubscriptionIDsOutsideMode :many
-- The subscriptions the operator views exclude under the current key
-- (stripe-environment-stamp D7). A NULL livemode is unverified and shows
-- under either key, so it is not in this list.
SELECT subscription_id FROM stripe.subscription_mappings
WHERE livemode IS NOT NULL AND livemode <> sqlc.arg(key_livemode)::boolean;
-- name: CountSubscriptionMappingsOutsideMode :one
SELECT COUNT(*) FROM stripe.subscription_mappings
WHERE livemode IS NOT NULL AND livemode <> sqlc.arg(key_livemode)::boolean;
@@ -14,7 +14,7 @@ import (
)
const getSubscriptionItemMappingByItemID = `-- name: GetSubscriptionItemMappingByItemID :one
SELECT subscription_item_id, stripe_subscription_item_id, sync_status, created_at, updated_at FROM stripe.subscription_item_mappings
SELECT subscription_item_id, stripe_subscription_item_id, sync_status, created_at, updated_at, livemode FROM stripe.subscription_item_mappings
WHERE subscription_item_id = $1
`
@@ -27,12 +27,13 @@ func (q *Queries) GetSubscriptionItemMappingByItemID(ctx context.Context, subscr
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
)
return i, err
}
const getSubscriptionItemMappingByStripeID = `-- name: GetSubscriptionItemMappingByStripeID :one
SELECT subscription_item_id, stripe_subscription_item_id, sync_status, created_at, updated_at FROM stripe.subscription_item_mappings
SELECT subscription_item_id, stripe_subscription_item_id, sync_status, created_at, updated_at, livemode FROM stripe.subscription_item_mappings
WHERE stripe_subscription_item_id = $1
`
@@ -45,28 +46,40 @@ func (q *Queries) GetSubscriptionItemMappingByStripeID(ctx context.Context, stri
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
)
return i, err
}
const upsertSubscriptionItemMapping = `-- name: UpsertSubscriptionItemMapping :one
INSERT INTO stripe.subscription_item_mappings (subscription_item_id, stripe_subscription_item_id, sync_status)
VALUES ($1, $2, $3)
INSERT INTO stripe.subscription_item_mappings (subscription_item_id, stripe_subscription_item_id, sync_status, livemode)
VALUES ($1, $2, $3, $4)
ON CONFLICT (subscription_item_id) DO UPDATE
SET stripe_subscription_item_id = EXCLUDED.stripe_subscription_item_id,
sync_status = EXCLUDED.sync_status,
livemode = COALESCE(EXCLUDED.livemode, subscription_item_mappings.livemode),
updated_at = NOW()
RETURNING subscription_item_id, stripe_subscription_item_id, sync_status, created_at, updated_at
RETURNING subscription_item_id, stripe_subscription_item_id, sync_status, created_at, updated_at, livemode
`
type UpsertSubscriptionItemMappingParams struct {
SubscriptionItemID string `json:"subscription_item_id"`
StripeSubscriptionItemID sql.NullString `json:"stripe_subscription_item_id"`
SyncStatus string `json:"sync_status"`
Livemode sql.NullBool `json:"livemode"`
}
// livemode is the parent subscription's flag: a Stripe subscription item
// carries none of its own (stripe-environment-stamp D1, D4). The conflict
// branch COALESCEs it so a caller with no flag to offer passes NULL
// without erasing a known environment.
func (q *Queries) UpsertSubscriptionItemMapping(ctx context.Context, arg UpsertSubscriptionItemMappingParams) (SubscriptionItemMapping, error) {
row := q.db.QueryRowContext(ctx, upsertSubscriptionItemMapping, arg.SubscriptionItemID, arg.StripeSubscriptionItemID, arg.SyncStatus)
row := q.db.QueryRowContext(ctx, upsertSubscriptionItemMapping,
arg.SubscriptionItemID,
arg.StripeSubscriptionItemID,
arg.SyncStatus,
arg.Livemode,
)
var i SubscriptionItemMapping
err := row.Scan(
&i.SubscriptionItemID,
@@ -74,6 +87,7 @@ func (q *Queries) UpsertSubscriptionItemMapping(ctx context.Context, arg UpsertS
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
)
return i, err
}
@@ -13,8 +13,20 @@ import (
"database/sql"
)
const countSubscriptionMappingsOutsideMode = `-- name: CountSubscriptionMappingsOutsideMode :one
SELECT COUNT(*) FROM stripe.subscription_mappings
WHERE livemode IS NOT NULL AND livemode <> $1::boolean
`
func (q *Queries) CountSubscriptionMappingsOutsideMode(ctx context.Context, keyLivemode bool) (int64, error) {
row := q.db.QueryRowContext(ctx, countSubscriptionMappingsOutsideMode, keyLivemode)
var count int64
err := row.Scan(&count)
return count, err
}
const getSubscriptionMappingByStripeID = `-- name: GetSubscriptionMappingByStripeID :one
SELECT subscription_id, stripe_subscription_id, sync_status, created_at, updated_at FROM stripe.subscription_mappings
SELECT subscription_id, stripe_subscription_id, sync_status, created_at, updated_at, livemode FROM stripe.subscription_mappings
WHERE stripe_subscription_id = $1
`
@@ -27,12 +39,13 @@ func (q *Queries) GetSubscriptionMappingByStripeID(ctx context.Context, stripeSu
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
)
return i, err
}
const getSubscriptionMappingBySubscriptionID = `-- name: GetSubscriptionMappingBySubscriptionID :one
SELECT subscription_id, stripe_subscription_id, sync_status, created_at, updated_at FROM stripe.subscription_mappings
SELECT subscription_id, stripe_subscription_id, sync_status, created_at, updated_at, livemode FROM stripe.subscription_mappings
WHERE subscription_id = $1
`
@@ -45,10 +58,42 @@ func (q *Queries) GetSubscriptionMappingBySubscriptionID(ctx context.Context, su
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
)
return i, err
}
const listSubscriptionIDsOutsideMode = `-- name: ListSubscriptionIDsOutsideMode :many
SELECT subscription_id FROM stripe.subscription_mappings
WHERE livemode IS NOT NULL AND livemode <> $1::boolean
`
// The subscriptions the operator views exclude under the current key
// (stripe-environment-stamp D7). A NULL livemode is unverified and shows
// under either key, so it is not in this list.
func (q *Queries) ListSubscriptionIDsOutsideMode(ctx context.Context, keyLivemode bool) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listSubscriptionIDsOutsideMode, keyLivemode)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var subscription_id string
if err := rows.Scan(&subscription_id); err != nil {
return nil, err
}
items = append(items, subscription_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateSubscriptionMappingSyncStatus = `-- name: UpdateSubscriptionMappingSyncStatus :exec
UPDATE stripe.subscription_mappings
SET sync_status = $2, updated_at = NOW()
@@ -66,23 +111,35 @@ func (q *Queries) UpdateSubscriptionMappingSyncStatus(ctx context.Context, arg U
}
const upsertSubscriptionMapping = `-- name: UpsertSubscriptionMapping :one
INSERT INTO stripe.subscription_mappings (subscription_id, stripe_subscription_id, sync_status)
VALUES ($1, $2, $3)
INSERT INTO stripe.subscription_mappings (subscription_id, stripe_subscription_id, sync_status, livemode)
VALUES ($1, $2, $3, $4)
ON CONFLICT (subscription_id) DO UPDATE
SET stripe_subscription_id = EXCLUDED.stripe_subscription_id,
sync_status = EXCLUDED.sync_status,
livemode = COALESCE(EXCLUDED.livemode, subscription_mappings.livemode),
updated_at = NOW()
RETURNING subscription_id, stripe_subscription_id, sync_status, created_at, updated_at
RETURNING subscription_id, stripe_subscription_id, sync_status, created_at, updated_at, livemode
`
type UpsertSubscriptionMappingParams struct {
SubscriptionID string `json:"subscription_id"`
StripeSubscriptionID sql.NullString `json:"stripe_subscription_id"`
SyncStatus string `json:"sync_status"`
Livemode sql.NullBool `json:"livemode"`
}
// livemode is the refetched subscription's own flag
// (stripe-environment-stamp D4): the console never creates a subscription,
// so the stamp rides the read-back the reconcile already performs. The
// conflict branch COALESCEs it so a caller with no flag to offer passes
// NULL without erasing a known environment.
func (q *Queries) UpsertSubscriptionMapping(ctx context.Context, arg UpsertSubscriptionMappingParams) (SubscriptionMapping, error) {
row := q.db.QueryRowContext(ctx, upsertSubscriptionMapping, arg.SubscriptionID, arg.StripeSubscriptionID, arg.SyncStatus)
row := q.db.QueryRowContext(ctx, upsertSubscriptionMapping,
arg.SubscriptionID,
arg.StripeSubscriptionID,
arg.SyncStatus,
arg.Livemode,
)
var i SubscriptionMapping
err := row.Scan(
&i.SubscriptionID,
@@ -90,6 +147,7 @@ func (q *Queries) UpsertSubscriptionMapping(ctx context.Context, arg UpsertSubsc
&i.SyncStatus,
&i.CreatedAt,
&i.UpdatedAt,
&i.Livemode,
)
return i, err
}
+52 -7
View File
@@ -54,7 +54,9 @@ package stripe
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"log/slog"
"net/http"
@@ -135,9 +137,17 @@ func (Adapter) RegisterRoutes(mux *http.ServeMux, deps server.Deps) error {
if webhookSecret == "" {
return nil
}
// The key's mode travels with the signing secret: both are read from
// the configuration this adapter owns, and the handler needs the mode
// to tell an event from the other world from one it should process
// (stripe-environment-stamp D6). ModeForKey's error is ignored here for
// the reason RegisterWorkflows ignores it: boot already refused an
// unrecognized prefix.
keyMode, _ := ModeForKey(viper.GetString("stripe-api-key"))
handler := &web.StripeWebhookHandler{
DB: deps.Database,
WebhookSecret: webhookSecret,
KeyMode: keyMode,
Logger: deps.Logger,
}
if deps.TemporalClient != nil {
@@ -163,13 +173,26 @@ func (Adapter) CSRFExemptPaths() []string {
}
// RegisterWorkflows registers Stripe's Temporal workflows and activities
// (webhook processing, outbox draining) against the shared worker.
// (webhook processing, outbox draining, the environment check) against the
// shared worker.
//
// The environment check's activity holds the mode and the fingerprint of
// the key this process runs under, read here from the same viper key
// Startup assigns to stripego.Key, so the activity's record and its log
// lines name the key the SDK is actually calling with
// (stripe-environment-stamp D3). ModeForKey's error is ignored: boot
// already refused an unrecognized prefix, and a worker is not the place to
// fail over it a second time.
func (Adapter) RegisterWorkflows(w worker.Worker, database *sql.DB, logger *slog.Logger) {
w.RegisterWorkflow(workflows.ProcessStripeWebhookEvent)
w.RegisterWorkflow(workflows.PollIntegrationOutbox)
w.RegisterWorkflow(workflows.ExecuteStripeOutboxEntry)
w.RegisterWorkflow(workflows.StripeEnvironmentCheckWorkflow)
w.RegisterActivity(workflows.NewWebhookActivities(database, logger))
w.RegisterActivity(workflows.NewOutboxActivities(database, logger))
apiKey := viper.GetString("stripe-api-key")
mode, _ := ModeForKey(apiKey)
w.RegisterActivity(workflows.NewEnvironmentCheckActivities(database, logger, mode, FingerprintForKey(apiKey)))
}
// Startup sets the stripe-go package-level API key — the single place this
@@ -235,17 +258,39 @@ const (
// here rather than declared as a setting that could disagree with it.
func ModeForKey(apiKey string) (string, error) {
key := strings.TrimSpace(apiKey)
switch {
case key == "":
if key == "" {
return "", nil
case strings.HasPrefix(key, "sk_live_"), strings.HasPrefix(key, "rk_live_"):
return ModeLive, nil
case strings.HasPrefix(key, "sk_test_"), strings.HasPrefix(key, "rk_test_"):
return ModeTest, nil
}
// The prefix classification lives in the store package, which the
// workflows and fulfillment packages can import without a cycle; this
// boot-time wrapper adds the one thing they must not do, refusing a key
// boot does not recognize.
if mode := stripemod.ModeForKey(key); mode != "" {
return mode, nil
}
return "", errors.New("stripe-api-key must begin with sk_live_, rk_live_, sk_test_, or rk_test_")
}
// FingerprintForKey is the API key's identity in the console's own
// records: the hex SHA-256 digest of the raw key, the shape
// internal/auth/auth.go already uses for the PKCE verifier. Boot computes
// it beside ModeForKey, the environment check records it, and the next
// boot compares the two to learn that the key moved
// (stripe-environment-stamp D3).
//
// The digest, never the key: the value is written to an instance setting
// and logged eight characters at a time, so a reversible form of it would
// put a live secret in the database and in the log. An empty key has no
// fingerprint, which is the state an unconfigured deployment stays in.
func FingerprintForKey(apiKey string) string {
key := strings.TrimSpace(apiKey)
if key == "" {
return ""
}
digest := sha256.Sum256([]byte(key))
return hex.EncodeToString(digest[:])
}
// DashboardURL builds the Stripe dashboard base URL operator deep links
// hang off, from the derived mode: `https://dashboard.stripe.com/test`
// in test mode and `https://dashboard.stripe.com` in live. The links are
@@ -76,3 +76,29 @@ func TestConfigSpecHasNoModeKey(t *testing.T) {
}
}
}
// TestFingerprintForKey covers stripe-integration-infrastructure ("Stripe
// mode is derived from the API key", the fingerprint scenario): the digest
// identifies the key across two boots and reveals nothing about it.
func TestFingerprintForKey(t *testing.T) {
const key = "sk_test_51abcdefghijklmnop"
got := FingerprintForKey(key)
if len(got) != 64 {
t.Fatalf("FingerprintForKey = %q, want a 64-character hex SHA-256 digest", got)
}
if strings.Contains(got, key) || strings.Contains(got, "sk_test_") {
t.Fatalf("the fingerprint carries the key: %q", got)
}
if got != FingerprintForKey(key) {
t.Fatal("the same key must fingerprint the same way on every boot")
}
if got == FingerprintForKey("sk_test_51abcdefghijklmnoq") {
t.Fatal("two keys must not share a fingerprint")
}
if FingerprintForKey(" "+key+" ") != got {
t.Fatal("surrounding whitespace is not part of the key, the same way ModeForKey reads it")
}
if FingerprintForKey("") != "" {
t.Fatal("an unconfigured deployment has no fingerprint")
}
}
+59 -5
View File
@@ -14,6 +14,7 @@ import (
"log/slog"
"net/http"
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
stripe "github.com/stripe/stripe-go/v81"
"github.com/stripe/stripe-go/v81/webhook"
)
@@ -77,7 +78,14 @@ type QueuedEvent struct {
type StripeWebhookHandler struct {
DB *sql.DB
WebhookSecret string
Logger *slog.Logger
// KeyMode is the environment the configured API key is in ("live",
// "test", or "" when no key is configured), handed in beside the
// signing secret by the adapter that owns both. An event from the
// other environment is captured and refused against it
// (stripe-environment-stamp D6); an empty KeyMode refuses nothing,
// since there is no key to disagree with.
KeyMode string
Logger *slog.Logger
// StartProcessing starts the recorded event's workflow. It must be
// idempotent for an execution already running, since a redelivery of an
// unfinished event calls it again. Nil records events without starting
@@ -87,9 +95,29 @@ type StripeWebhookHandler struct {
// unfinishedStatuses are the row states in which a redelivery re-issues the
// workflow start: the event was recorded but its workflow may never have
// started (the process died in between and Stripe got no 200).
// started (the process died in between and Stripe got no 200). `refused`
// is deliberately absent: a refused event never had a workflow and never
// gets one, so its redelivery is answered and dropped
// (stripe-environment-stamp D6).
var unfinishedStatuses = map[string]bool{"received": true, "processing": true, "failed": true}
// statusRefused is the row state of an event from the other Stripe
// environment: captured, so an operator can count it on the provider page,
// and not processed, because its objects do not exist under the key in
// force.
const statusRefused = "refused"
// eventEnvironment turns the envelope's livemode into the word the
// provider_environment column holds. core.webhook_events is
// provider-neutral, so the column carries Stripe's own vocabulary the way
// event_type does (design D6).
func eventEnvironment(livemode bool) string {
if livemode {
return internalstripe.ModeLive
}
return internalstripe.ModeTest
}
// ServeHTTP verifies the Stripe signature, scrubs PII, inserts the event
// idempotently into core.webhook_events, and starts the event's workflow.
// It acknowledges with 2xx only once the event is durably recorded and its
@@ -97,6 +125,11 @@ var unfinishedStatuses = map[string]bool{"received": true, "processing": true, "
// insert or a failed start answers 5xx so Stripe redelivers instead of the
// event being silently dropped. The redelivery of an unfinished duplicate
// starts the workflow again, which is a no-op when it is already running.
//
// An event whose environment disagrees with the key's mode is recorded
// `refused` and answered 200 with no workflow started: the row is the
// evidence the provider page counts, and Stripe stops redelivering an
// event this deployment will never process (design D6).
func (h *StripeWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -157,19 +190,40 @@ func (h *StripeWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
// insert a second row and reprocess. WHERE NOT EXISTS dedupes on
// (provider, provider_event_id) across time; the remaining
// concurrent-delivery race is the same one ON CONFLICT left open.
// The envelope's world, which the handler used to discard, and whether
// it is the world the key can reach. A refused event is recorded like
// any other and processed like none: its objects exist somewhere the
// current key cannot read (design D6).
eventMode := eventEnvironment(event.Livemode)
refused := h.KeyMode != "" && eventMode != h.KeyMode
status := "received"
if refused {
status = statusRefused
}
var rowID int64
err = h.DB.QueryRowContext(r.Context(),
`INSERT INTO core.webhook_events
(provider, provider_event_id, event_type, payload, status, provider_event_at)
SELECT $1, $2, $3, $4, 'received', CASE WHEN $5::bigint > 0 THEN to_timestamp($5::bigint) END
(provider, provider_event_id, event_type, payload, status, provider_event_at, provider_environment)
SELECT $1, $2, $3, $4, $6, CASE WHEN $5::bigint > 0 THEN to_timestamp($5::bigint) END, $7
WHERE NOT EXISTS (
SELECT 1 FROM core.webhook_events
WHERE provider = $1 AND provider_event_id = $2
)
RETURNING id`,
"stripe", event.ID, string(event.Type), scrubbedJSON, event.Created,
"stripe", event.ID, string(event.Type), scrubbedJSON, event.Created, status, eventMode,
).Scan(&rowID)
switch {
case err == nil && refused:
h.Logger.Warn("stripe: webhook event mode disagrees with the key; captured, not processed",
slog.String("event_id", event.ID),
slog.String("event_type", string(event.Type)),
slog.String("event_mode", eventMode),
slog.String("key_mode", h.KeyMode))
// 200, so Stripe stops redelivering an event nothing here will
// ever process.
w.WriteHeader(http.StatusOK)
return
case err == nil:
h.Logger.Info("webhook event received",
slog.String("event_id", event.ID),
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package web_test
import (
"fmt"
"log/slog"
"net/http"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/web"
"github.com/google/uuid"
)
// eventPayload is a signed-shaped Stripe envelope with the livemode flag
// the handler now reads (stripe-environment-stamp D6).
func eventPayload(eventID string, livemode bool) []byte {
return []byte(fmt.Sprintf(
`{"id": %q, "type": "customer.created", "livemode": %t, "data": {"object": {"id": "cus_env"}}}`,
eventID, livemode))
}
// TestWebhookRefusesEventFromTheOtherEnvironment: an event from the
// environment the key cannot reach is captured and not processed. 200,
// because nothing here will ever process it and a redelivery would only
// record the same fact again.
func TestWebhookRefusesEventFromTheOtherEnvironment(t *testing.T) {
database := testDB(t)
starter := &recordingStarter{}
h := &web.StripeWebhookHandler{
DB: database, WebhookSecret: testSecret, KeyMode: "live",
Logger: slog.Default(), StartProcessing: starter.start,
}
eventID := "evt_refused_" + uuid.New().String()[:12]
t.Cleanup(func() {
_, _ = database.Exec(`DELETE FROM core.webhook_events WHERE provider = 'stripe' AND provider_event_id = $1`, eventID)
})
if code := deliver(t, h, eventPayload(eventID, false)); code != http.StatusOK {
t.Fatalf("delivery of a test-mode event under a live key = %d, want 200", code)
}
var status, environment string
if err := database.QueryRow(
`SELECT status, provider_environment FROM core.webhook_events
WHERE provider = 'stripe' AND provider_event_id = $1`, eventID).Scan(&status, &environment); err != nil {
t.Fatalf("read row: %v", err)
}
if status != "refused" {
t.Errorf("status = %q, want refused", status)
}
if environment != "test" {
t.Errorf("provider_environment = %q, want test", environment)
}
if len(starter.calls) != 0 {
t.Fatalf("a refused event must start no workflow, got %+v", starter.calls)
}
// A redelivery is answered and starts nothing: refused is not an
// unfinished status, so neither this nor the boot sweep picks it up.
if code := deliver(t, h, eventPayload(eventID, false)); code != http.StatusOK {
t.Fatalf("redelivery of a refused event = %d, want 200", code)
}
if len(starter.calls) != 0 {
t.Fatalf("a redelivered refused event must start no workflow, got %+v", starter.calls)
}
var rows int
if err := database.QueryRow(
`SELECT COUNT(*) FROM core.webhook_events WHERE provider = 'stripe' AND provider_event_id = $1`,
eventID).Scan(&rows); err != nil {
t.Fatalf("count rows: %v", err)
}
if rows != 1 {
t.Fatalf("the redelivery wrote a second row: %d", rows)
}
}
// TestWebhookRecordsTheEnvironmentOfAnAgreeingEvent: an event from the
// key's own environment is recorded with that environment and processed
// exactly as before.
func TestWebhookRecordsTheEnvironmentOfAnAgreeingEvent(t *testing.T) {
database := testDB(t)
starter := &recordingStarter{}
h := &web.StripeWebhookHandler{
DB: database, WebhookSecret: testSecret, KeyMode: "test",
Logger: slog.Default(), StartProcessing: starter.start,
}
eventID := "evt_agree_" + uuid.New().String()[:12]
t.Cleanup(func() {
_, _ = database.Exec(`DELETE FROM core.webhook_events WHERE provider = 'stripe' AND provider_event_id = $1`, eventID)
})
if code := deliver(t, h, eventPayload(eventID, false)); code != http.StatusOK {
t.Fatalf("delivery of a test-mode event under a test key = %d, want 200", code)
}
var status, environment string
if err := database.QueryRow(
`SELECT status, provider_environment FROM core.webhook_events
WHERE provider = 'stripe' AND provider_event_id = $1`, eventID).Scan(&status, &environment); err != nil {
t.Fatalf("read row: %v", err)
}
if status != "received" {
t.Errorf("status = %q, want received", status)
}
if environment != "test" {
t.Errorf("provider_environment = %q, want test", environment)
}
if len(starter.calls) != 1 {
t.Fatalf("an agreeing event must start its workflow once, got %+v", starter.calls)
}
}
// TestWebhookWithNoKeyModeRefusesNothing: a deployment with no usable key
// has nothing to disagree with, so the handler records the environment and
// processes the event as it always did.
func TestWebhookWithNoKeyModeRefusesNothing(t *testing.T) {
database := testDB(t)
starter := &recordingStarter{}
h := &web.StripeWebhookHandler{
DB: database, WebhookSecret: testSecret,
Logger: slog.Default(), StartProcessing: starter.start,
}
eventID := "evt_nokey_" + uuid.New().String()[:12]
t.Cleanup(func() {
_, _ = database.Exec(`DELETE FROM core.webhook_events WHERE provider = 'stripe' AND provider_event_id = $1`, eventID)
})
if code := deliver(t, h, eventPayload(eventID, true)); code != http.StatusOK {
t.Fatalf("delivery = %d, want 200", code)
}
var status, environment string
if err := database.QueryRow(
`SELECT status, provider_environment FROM core.webhook_events
WHERE provider = 'stripe' AND provider_event_id = $1`, eventID).Scan(&status, &environment); err != nil {
t.Fatalf("read row: %v", err)
}
if status != "received" {
t.Errorf("status = %q, want received", status)
}
if environment != "live" {
t.Errorf("provider_environment = %q, want live", environment)
}
if len(starter.calls) != 1 {
t.Fatalf("with no key mode the event is processed, got %+v", starter.calls)
}
}
@@ -185,6 +185,12 @@ func TestOutboxExecutor_CreateStripeCustomer(t *testing.T) {
if !mapping.StripeCustomerID.Valid {
t.Fatal("expected stripe_customer_id to be set")
}
// The stamp is the Risks section's guard against a wrong environment:
// it comes from the object Stripe returned, so it is set whenever the
// create succeeded (stripe-environment-stamp D1).
if !mapping.Livemode.Valid {
t.Error("expected livemode to be recorded from the object Stripe returned")
}
}
func TestWebhookProcessor_CustomerCreated_IdempotentUpsert(t *testing.T) {
@@ -219,7 +225,7 @@ func TestWebhookProcessor_CustomerCreated_IdempotentUpsert(t *testing.T) {
`INSERT INTO core.webhook_events (provider, provider_event_id, event_type, payload, status)
VALUES ('stripe', $1, 'customer.created', $2, 'received')
RETURNING id`,
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s"}`, cusID)),
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s", "livemode": false}`, cusID)),
).Scan(&evtID)
if err != nil {
t.Fatalf("insert webhook event: %v", err)
@@ -289,7 +295,7 @@ func TestWebhookProcessor_CustomerDeleted(t *testing.T) {
`INSERT INTO core.webhook_events (provider, provider_event_id, event_type, payload, status)
VALUES ('stripe', $1, 'customer.deleted', $2, 'received')
RETURNING id`,
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s"}`, cusID)),
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s", "livemode": false}`, cusID)),
).Scan(&evtID)
if err != nil {
t.Fatalf("insert webhook event: %v", err)
@@ -322,3 +328,73 @@ func TestWebhookProcessor_CustomerDeleted(t *testing.T) {
t.Fatalf("expected sync_status 'deleted', got %q", mapping.SyncStatus)
}
}
// TestWebhookProcessor_CustomerUpdated_RecordsTheEnvironment covers the
// customer.updated write, which touches the mapping's timestamp and, since
// stripe-environment-stamp D1, records the environment the parsed customer
// reports. The mapping is seeded unverified, as every row is after the
// migration, so the event is the first thing that knows.
func TestWebhookProcessor_CustomerUpdated_RecordsTheEnvironment(t *testing.T) {
database := testDB(t)
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin tx: %v", err)
}
orgID, _ := createTestOrg(t, ctx, tx)
baID := createTestBillingAccount(t, ctx, tx, orgID)
cusID := fmt.Sprintf("cus_updated_%d", rand.Int63())
evtProviderID := fmt.Sprintf("evt_updated_%d", rand.Int63())
q := internalstripe.New(tx)
if _, err := q.UpsertCustomerMapping(ctx, internalstripe.UpsertCustomerMappingParams{
BillingAccountID: baID,
StripeCustomerID: sql.NullString{String: cusID, Valid: true},
SyncStatus: "synced",
}); err != nil {
t.Fatalf("insert mapping: %v", err)
}
var evtID int64
err = tx.QueryRowContext(ctx,
`INSERT INTO core.webhook_events (provider, provider_event_id, event_type, payload, status)
VALUES ('stripe', $1, 'customer.updated', $2, 'received')
RETURNING id`,
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s", "livemode": false}`, cusID)),
).Scan(&evtID)
if err != nil {
t.Fatalf("insert webhook event: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
acts := stripewf.NewWebhookActivities(database, logger)
if err := acts.ProcessWebhookEvent(ctx, stripewf.WebhookEvent{
ID: evtID,
Provider: "stripe",
ProviderEventID: evtProviderID,
EventType: "customer.updated",
Status: "received",
}); err != nil {
t.Fatalf("ProcessWebhookEvent: %v", err)
}
qq := internalstripe.New(database)
mapping, err := qq.GetCustomerMappingByBillingAccountID(ctx, baID)
if err != nil {
t.Fatalf("get mapping after webhook: %v", err)
}
if mapping.SyncStatus != "synced" {
t.Errorf("sync_status = %q, want it unchanged at 'synced'", mapping.SyncStatus)
}
if !mapping.StripeCustomerID.Valid || mapping.StripeCustomerID.String != cusID {
t.Errorf("stripe_customer_id = %+v, want it unchanged at %q", mapping.StripeCustomerID, cusID)
}
if !mapping.Livemode.Valid || mapping.Livemode.Bool {
t.Errorf("livemode after customer.updated = %+v, want a recorded test mode", mapping.Livemode)
}
}
@@ -0,0 +1,278 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/instance"
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
stripego "github.com/stripe/stripe-go/v81"
"github.com/stripe/stripe-go/v81/price"
"github.com/stripe/stripe-go/v81/product"
"go.temporal.io/sdk/activity"
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/workflow"
)
// EnvironmentCheckWorkflowID is the fixed id the check runs under, so a
// second start while one runs is refused by Temporal rather than
// deduplicated by the console (stripe-environment-stamp D3). Boot and the
// provider page's control start the same id.
const EnvironmentCheckWorkflowID = "stripe-environment-check"
// The two triggers a check is started by: boot, when the key's fingerprint
// differs from the recorded one, and an operator pressing the provider
// page's control. The value rides the input so the start log line says
// which one asked.
const (
EnvironmentCheckTriggerBoot = "boot"
EnvironmentCheckTriggerOperator = "operator"
)
// The activity's budget. A deployment's largest catalog today is a few
// hundred product and price mappings, read back one at a time, so half an
// hour is generous for one attempt; the heartbeat per row means a run
// wedged on a hanging request is noticed in two minutes rather than at the
// end of that budget. A retry re-reads every row and writes the same
// answers, so there is nothing to resume.
const (
environmentCheckAttemptTimeout = 30 * time.Minute
environmentCheckHeartbeat = 2 * time.Minute
environmentCheckRetryBudget = 2 * time.Hour
)
// EnvironmentCheckInput carries the trigger into the workflow and its
// activity.
type EnvironmentCheckInput struct {
Trigger string `json:"trigger"`
}
// EnvironmentCheckResult is what the run found: every mapping it read back
// and the subset Stripe could not find under the current key.
type EnvironmentCheckResult struct {
Checked int `json:"checked"`
Stale int `json:"stale"`
}
// Verified is the count the provider page's control reports: the rows that
// resolved.
func (r EnvironmentCheckResult) Verified() int {
if r.Checked < r.Stale {
return 0
}
return r.Checked - r.Stale
}
// StripeEnvironmentCheckWorkflow reads every mapped product and price id
// back under the key in force (stripe-environment-stamp D3). One activity
// does the whole run: the reads are sequential by design, so splitting
// them across activities would buy nothing and cost a history event per
// row.
func StripeEnvironmentCheckWorkflow(ctx workflow.Context, input EnvironmentCheckInput) (EnvironmentCheckResult, error) {
var acts *EnvironmentCheckActivities
actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
StartToCloseTimeout: environmentCheckAttemptTimeout,
ScheduleToCloseTimeout: environmentCheckRetryBudget,
HeartbeatTimeout: environmentCheckHeartbeat,
RetryPolicy: &temporal.RetryPolicy{
InitialInterval: 5 * time.Second,
BackoffCoefficient: 2.0,
MaximumInterval: 5 * time.Minute,
},
})
var result EnvironmentCheckResult
if err := workflow.ExecuteActivity(actCtx, acts.CheckMappingEnvironments, input).Get(ctx, &result); err != nil {
workflow.GetLogger(ctx).Error("stripe environment check exhausted its retries",
"trigger", input.Trigger, "error", err)
return EnvironmentCheckResult{}, err
}
return result, nil
}
// StartEnvironmentCheck starts the check from outside a workflow: boot and
// the provider page's control. It carries no conflict policy, so Temporal
// refuses a start issued while a run holds the id and the caller learns it
// from the error (temporal.IsWorkflowExecutionAlreadyStartedError) rather
// than from a state the console keeps for itself.
func StartEnvironmentCheck(ctx context.Context, c client.Client, taskQueue, trigger string) (client.WorkflowRun, error) {
return c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
ID: EnvironmentCheckWorkflowID,
TaskQueue: taskQueue,
}, StripeEnvironmentCheckWorkflow, EnvironmentCheckInput{Trigger: trigger})
}
// EnvironmentCheckActivities holds what the read-back needs: the database
// the mappings and the record live in, a logger, and the mode and
// fingerprint of the key the process holds. The last two are passed in
// rather than derived here because the key itself belongs to the adapter
// that sets it process-wide (stripe.Adapter.Startup), and an activity that
// read the key again could disagree with the one the SDK is calling with.
type EnvironmentCheckActivities struct {
DB *sql.DB
Logger *slog.Logger
Mode string
Fingerprint string
}
// NewEnvironmentCheckActivities builds the check's activity set.
func NewEnvironmentCheckActivities(db *sql.DB, logger *slog.Logger, mode, fingerprint string) *EnvironmentCheckActivities {
return &EnvironmentCheckActivities{DB: db, Logger: logger, Mode: mode, Fingerprint: fingerprint}
}
// CheckMappingEnvironments reads every mapped product and price id back
// under the current key, one at a time, heartbeating per row
// (stripe-environment-stamp D3). A resolved id records the object's own
// livemode and the time of the read, and returns a row an earlier run
// marked stale to synced; an id Stripe cannot find is marked stale with
// its recorded livemode left alone, because the object still exists in the
// environment that made it. Any other answer fails the activity so
// Temporal retries it: the rows already written stay written and a rerun
// writes them the same way.
func (a *EnvironmentCheckActivities) CheckMappingEnvironments(ctx context.Context, input EnvironmentCheckInput) (EnvironmentCheckResult, error) {
q := internalstripe.New(a.DB)
products, err := q.ListProductMappingsForCheck(ctx)
if err != nil {
return EnvironmentCheckResult{}, fmt.Errorf("list product mappings for check: %w", err)
}
prices, err := q.ListPriceMappingsForCheck(ctx)
if err != nil {
return EnvironmentCheckResult{}, fmt.Errorf("list price mappings for check: %w", err)
}
started := time.Now().UTC()
if err := a.writeRecord(ctx, internalstripe.EnvironmentCheckRecord{
KeyFingerprint: a.Fingerprint,
StartedAt: &started,
}); err != nil {
return EnvironmentCheckResult{}, err
}
a.Logger.Info("stripe: environment check started",
slog.Int("mappings", len(products)+len(prices)),
slog.String("mode", a.Mode),
slog.String("key_fingerprint", internalstripe.FingerprintPrefix(a.Fingerprint)),
slog.String("trigger", input.Trigger))
var result EnvironmentCheckResult
for _, row := range products {
if !row.StripeProductID.Valid {
continue
}
heartbeat(ctx, row.ProductID)
obj, err := product.Get(row.StripeProductID.String, nil)
switch {
case err == nil:
if err := q.MarkProductMappingVerified(ctx, internalstripe.MarkProductMappingVerifiedParams{
Livemode: obj.Livemode, ProductID: row.ProductID,
}); err != nil {
return result, fmt.Errorf("record verified product mapping %s: %w", row.ProductID, err)
}
case isResourceMissing(err):
if err := q.MarkProductMappingStale(ctx, row.ProductID); err != nil {
return result, fmt.Errorf("mark product mapping %s stale: %w", row.ProductID, err)
}
result.Stale++
a.logMissing("stripe.product_mappings", row.ProductID, row.StripeProductID.String)
default:
return result, fmt.Errorf("stripe get product %s: %w", row.StripeProductID.String, err)
}
result.Checked++
}
for _, row := range prices {
if !row.StripePriceID.Valid {
continue
}
heartbeat(ctx, row.PriceID)
obj, err := price.Get(row.StripePriceID.String, nil)
switch {
case err == nil:
if err := q.MarkPriceMappingVerified(ctx, internalstripe.MarkPriceMappingVerifiedParams{
Livemode: obj.Livemode, PriceID: row.PriceID,
}); err != nil {
return result, fmt.Errorf("record verified price mapping %s: %w", row.PriceID, err)
}
case isResourceMissing(err):
if err := q.MarkPriceMappingStale(ctx, row.PriceID); err != nil {
return result, fmt.Errorf("mark price mapping %s stale: %w", row.PriceID, err)
}
result.Stale++
a.logMissing("stripe.price_mappings", row.PriceID, row.StripePriceID.String)
default:
return result, fmt.Errorf("stripe get price %s: %w", row.StripePriceID.String, err)
}
result.Checked++
}
finished := time.Now().UTC()
if err := a.writeRecord(ctx, internalstripe.EnvironmentCheckRecord{
KeyFingerprint: a.Fingerprint,
StartedAt: &started,
FinishedAt: &finished,
Checked: result.Checked,
Stale: result.Stale,
}); err != nil {
return result, err
}
a.Logger.Info("stripe: environment check complete",
slog.Int("checked", result.Checked),
slog.Int("stale", result.Stale),
slog.String("mode", a.Mode),
slog.String("key_fingerprint", internalstripe.FingerprintPrefix(a.Fingerprint)))
return result, nil
}
// heartbeat reports progress to Temporal, which is how a run wedged on one
// row is noticed before the whole attempt's budget is spent. Guarded by
// IsActivity for the same reason currentAttempt is (webhook.go): the
// activity is also called directly by its tests, where there is no
// activity context to heartbeat into.
func heartbeat(ctx context.Context, detail string) {
if activity.IsActivity(ctx) {
activity.RecordHeartbeat(ctx, detail)
}
}
// logMissing names one id the current key cannot reach. table, the row id
// and the Stripe id together say which mapping to look at, and the mode
// says which world was asked.
func (a *EnvironmentCheckActivities) logMissing(table, rowID, stripeID string) {
a.Logger.Warn("stripe: mapping id missing under the current key",
slog.String("table", table),
slog.String("row_id", rowID),
slog.String("stripe_id", stripeID),
slog.String("mode", a.Mode))
}
// writeRecord stores the run's state in the instance setting. The write is
// not incidental to the run: the record is the only thing the provider page
// and the next boot read about it, so a run whose record was not written
// has no outcome anyone can see. A failed write therefore fails the
// activity and Temporal retries the whole read-back, which rewrites every
// row the same way.
func (a *EnvironmentCheckActivities) writeRecord(ctx context.Context, rec internalstripe.EnvironmentCheckRecord) error {
// updatedBy is empty: no operator wrote this, the check did.
if err := instance.NewStore(a.DB).SetJSON(ctx, instance.StripeEnvironmentCheck, rec, ""); err != nil {
return fmt.Errorf("record the environment check: %w", err)
}
return nil
}
// isResourceMissing reports whether Stripe's answer is that the id does not
// exist under the key it was asked with, the one answer the check treats as
// a finding rather than a fault.
func isResourceMissing(err error) bool {
var stripeErr *stripego.Error
if !errors.As(err, &stripeErr) {
return false
}
return stripeErr.Code == stripego.ErrorCodeResourceMissing
}
@@ -0,0 +1,381 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows_test
import (
"context"
"database/sql"
"fmt"
"log/slog"
"math/rand"
"os"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/instance"
internalstripe "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"
stripego "github.com/stripe/stripe-go/v81"
)
// The environment check reads every mapped product and price id back, not
// a subset, so a test cannot scope it to its own rows: the shared database
// holds whatever other tests left behind. Each test below therefore gives
// the fake backend a default object, so every row it did not name resolves
// and the run reaches the end, and asserts only on the rows it created.
// checkFixture is one product with one price, each with a Stripe mapping,
// created for a single test and removed after it.
type checkFixture struct {
productID string
priceID string
stripeProductID string
stripePriceID string
}
func newCheckFixture(t *testing.T, ctx context.Context, database *sql.DB) checkFixture {
t.Helper()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin tx: %v", err)
}
defer func() { _ = tx.Rollback() }()
productID, _ := createTestProduct(t, ctx, tx)
var priceID string
if err := tx.QueryRowContext(ctx,
`INSERT INTO core.prices (product_id, currency, unit_amount, recurring_interval, is_active)
VALUES ($1, 'usd', 900, 'month', TRUE)
RETURNING price_id`, productID,
).Scan(&priceID); err != nil {
t.Fatalf("insert price: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
fx := checkFixture{
productID: productID,
priceID: priceID,
stripeProductID: fmt.Sprintf("prod_check_%d", rand.Int63()),
stripePriceID: fmt.Sprintf("price_check_%d", rand.Int63()),
}
t.Cleanup(func() {
_, _ = database.Exec(`DELETE FROM stripe.price_mappings WHERE price_id = $1`, fx.priceID)
_, _ = database.Exec(`DELETE FROM stripe.product_mappings WHERE product_id = $1`, fx.productID)
_, _ = database.Exec(`DELETE FROM core.prices WHERE price_id = $1`, fx.priceID)
_, _ = database.Exec(`DELETE FROM core.products WHERE product_id = $1`, fx.productID)
})
return fx
}
// mapFixture writes the two mapping rows at a given sync status, with no
// recorded environment and no verification, the state every row is in
// after the migration.
func (fx checkFixture) mapFixture(t *testing.T, ctx context.Context, database *sql.DB, syncStatus string) {
t.Helper()
q := internalstripe.New(database)
if _, err := q.UpsertProductMapping(ctx, internalstripe.UpsertProductMappingParams{
ProductID: fx.productID,
StripeProductID: sql.NullString{String: fx.stripeProductID, Valid: true},
SyncStatus: syncStatus,
}); err != nil {
t.Fatalf("map product: %v", err)
}
if _, err := q.UpsertPriceMapping(ctx, internalstripe.UpsertPriceMappingParams{
PriceID: fx.priceID,
StripePriceID: sql.NullString{String: fx.stripePriceID, Valid: true},
SyncStatus: syncStatus,
}); err != nil {
t.Fatalf("map price: %v", err)
}
}
func checkActivities(database *sql.DB) *stripewf.EnvironmentCheckActivities {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
return stripewf.NewEnvironmentCheckActivities(database, logger, internalstripe.ModeTest, "fingerprint-under-test")
}
// TestEnvironmentCheckVerifiesResolvingIDs: an id Stripe answers for takes
// the object's own livemode and a verification time. The two objects carry
// different flags on purpose — the stamp is a fact about the object, never
// derived from the key (design D1's rule, which the check must not break).
func TestEnvironmentCheckVerifiesResolvingIDs(t *testing.T) {
database := testDB(t)
ctx := context.Background()
fx := newCheckFixture(t, ctx, database)
fx.mapFixture(t, ctx, database, "synced")
t.Cleanup(stripetest.Install(&stripetest.MockBackend{
Products: map[string]*stripego.Product{
fx.stripeProductID: {ID: fx.stripeProductID, Livemode: true},
},
Prices: map[string]*stripego.Price{
fx.stripePriceID: {ID: fx.stripePriceID, Livemode: false},
},
DefaultProduct: &stripego.Product{Livemode: false},
DefaultPrice: &stripego.Price{Livemode: false},
}))
result, err := checkActivities(database).CheckMappingEnvironments(ctx,
stripewf.EnvironmentCheckInput{Trigger: stripewf.EnvironmentCheckTriggerOperator})
if err != nil {
t.Fatalf("check: %v", err)
}
if result.Checked == 0 {
t.Fatal("the run reported no rows read back")
}
q := internalstripe.New(database)
prod, err := q.GetProductMappingByProductID(ctx, fx.productID)
if err != nil {
t.Fatalf("read product mapping: %v", err)
}
if !prod.Livemode.Valid || !prod.Livemode.Bool {
t.Errorf("product mapping livemode = %+v, want the object's true", prod.Livemode)
}
if !prod.VerifiedAt.Valid {
t.Error("a resolving product id must record when it was verified")
}
price, err := q.GetPriceMappingByPriceID(ctx, fx.priceID)
if err != nil {
t.Fatalf("read price mapping: %v", err)
}
if !price.Livemode.Valid || price.Livemode.Bool {
t.Errorf("price mapping livemode = %+v, want the object's false", price.Livemode)
}
if !price.VerifiedAt.Valid {
t.Error("a resolving price id must record when it was verified")
}
}
// TestEnvironmentCheckMarksMissingIDStale: resource_missing is a finding,
// not a fault. The row goes stale with a verification time and keeps the
// environment it recorded, because the object still exists in the
// environment that made it.
func TestEnvironmentCheckMarksMissingIDStale(t *testing.T) {
database := testDB(t)
ctx := context.Background()
fx := newCheckFixture(t, ctx, database)
fx.mapFixture(t, ctx, database, "synced")
if err := internalstripe.New(database).MarkProductMappingVerified(ctx,
internalstripe.MarkProductMappingVerifiedParams{Livemode: false, ProductID: fx.productID}); err != nil {
t.Fatalf("pre-stamp the product mapping: %v", err)
}
// The fixture's product id is named by neither map and both defaults
// are set, so only it answers resource_missing.
t.Cleanup(stripetest.Install(&stripetest.MockBackend{
ObjectErrs: map[string]error{
fx.stripeProductID: &stripego.Error{
Type: stripego.ErrorTypeInvalidRequest, Code: stripego.ErrorCodeResourceMissing, HTTPStatusCode: 404,
},
},
DefaultProduct: &stripego.Product{Livemode: false},
DefaultPrice: &stripego.Price{Livemode: false},
}))
result, err := checkActivities(database).CheckMappingEnvironments(ctx,
stripewf.EnvironmentCheckInput{Trigger: stripewf.EnvironmentCheckTriggerBoot})
if err != nil {
t.Fatalf("check: %v", err)
}
if result.Stale == 0 {
t.Fatal("the run reported no stale rows")
}
prod, err := internalstripe.New(database).GetProductMappingByProductID(ctx, fx.productID)
if err != nil {
t.Fatalf("read product mapping: %v", err)
}
if prod.SyncStatus != internalstripe.SyncStatusStale {
t.Errorf("sync_status = %q, want %q", prod.SyncStatus, internalstripe.SyncStatusStale)
}
if !prod.VerifiedAt.Valid {
t.Error("a stale row must record when the read-back found it missing")
}
if !prod.Livemode.Valid || prod.Livemode.Bool {
t.Errorf("livemode = %+v, want the recorded false left alone", prod.Livemode)
}
}
// TestEnvironmentCheckReturnsStaleRowToSynced: a stale id that resolves
// again is reachable again, so the row leaves stale.
func TestEnvironmentCheckReturnsStaleRowToSynced(t *testing.T) {
database := testDB(t)
ctx := context.Background()
fx := newCheckFixture(t, ctx, database)
fx.mapFixture(t, ctx, database, internalstripe.SyncStatusStale)
t.Cleanup(stripetest.Install(&stripetest.MockBackend{
DefaultProduct: &stripego.Product{Livemode: false},
DefaultPrice: &stripego.Price{Livemode: false},
}))
if _, err := checkActivities(database).CheckMappingEnvironments(ctx,
stripewf.EnvironmentCheckInput{Trigger: stripewf.EnvironmentCheckTriggerOperator}); err != nil {
t.Fatalf("check: %v", err)
}
q := internalstripe.New(database)
prod, err := q.GetProductMappingByProductID(ctx, fx.productID)
if err != nil {
t.Fatalf("read product mapping: %v", err)
}
if prod.SyncStatus != "synced" {
t.Errorf("product sync_status = %q, want synced", prod.SyncStatus)
}
price, err := q.GetPriceMappingByPriceID(ctx, fx.priceID)
if err != nil {
t.Fatalf("read price mapping: %v", err)
}
if price.SyncStatus != "synced" {
t.Errorf("price sync_status = %q, want synced", price.SyncStatus)
}
}
// TestEnvironmentCheckFailsOnOtherErrors: an answer that is not
// resource_missing says nothing about where the id lives, so the activity
// fails and Temporal retries it rather than writing a verdict.
func TestEnvironmentCheckFailsOnOtherErrors(t *testing.T) {
database := testDB(t)
ctx := context.Background()
fx := newCheckFixture(t, ctx, database)
fx.mapFixture(t, ctx, database, "synced")
t.Cleanup(stripetest.Install(&stripetest.MockBackend{
ObjectErrs: map[string]error{
fx.stripeProductID: &stripego.Error{
Type: stripego.ErrorTypeAPI, Code: stripego.ErrorCodeRateLimit, HTTPStatusCode: 429,
},
},
DefaultProduct: &stripego.Product{Livemode: false},
DefaultPrice: &stripego.Price{Livemode: false},
}))
if _, err := checkActivities(database).CheckMappingEnvironments(ctx,
stripewf.EnvironmentCheckInput{Trigger: stripewf.EnvironmentCheckTriggerBoot}); err == nil {
t.Fatal("a rate-limit answer must fail the activity, not be read as a finding")
}
prod, err := internalstripe.New(database).GetProductMappingByProductID(ctx, fx.productID)
if err != nil {
t.Fatalf("read product mapping: %v", err)
}
if prod.SyncStatus == internalstripe.SyncStatusStale {
t.Error("an error that is not resource_missing must not mark the row stale")
}
}
// readCheckRecord reads the run's record back through the instance
// settings store, which is where the activity writes it and where the
// provider page and the next boot read it.
func readCheckRecord(t *testing.T, ctx context.Context, database *sql.DB) internalstripe.EnvironmentCheckRecord {
t.Helper()
raw, found, err := instance.NewStore(database).GetJSON(ctx, instance.StripeEnvironmentCheck)
if err != nil {
t.Fatalf("read the environment check record: %v", err)
}
rec, ok := internalstripe.DecodeEnvironmentCheckRecord(raw, found)
if !ok {
t.Fatal("the run wrote no readable environment check record")
}
return rec
}
// TestEnvironmentCheckRecordsItsStart: the record is written before the
// first read-back, so the provider page says a run is under way while it
// runs. The run here fails on a rate-limit answer, which leaves exactly
// what the start wrote: this key's fingerprint, a start, no finish and no
// counts.
func TestEnvironmentCheckRecordsItsStart(t *testing.T) {
database := testDB(t)
ctx := context.Background()
fx := newCheckFixture(t, ctx, database)
fx.mapFixture(t, ctx, database, "synced")
t.Cleanup(stripetest.Install(&stripetest.MockBackend{
ObjectErrs: map[string]error{
fx.stripeProductID: &stripego.Error{
Type: stripego.ErrorTypeAPI, Code: stripego.ErrorCodeRateLimit, HTTPStatusCode: 429,
},
},
DefaultProduct: &stripego.Product{Livemode: false},
DefaultPrice: &stripego.Price{Livemode: false},
}))
if _, err := checkActivities(database).CheckMappingEnvironments(ctx,
stripewf.EnvironmentCheckInput{Trigger: stripewf.EnvironmentCheckTriggerBoot}); err == nil {
t.Fatal("a rate-limit answer must fail the activity")
}
rec := readCheckRecord(t, ctx, database)
if rec.KeyFingerprint != "fingerprint-under-test" {
t.Errorf("key_fingerprint = %q, want the key the run held", rec.KeyFingerprint)
}
if rec.StartedAt == nil {
t.Error("the record must carry the time the run started")
}
if rec.FinishedAt != nil {
t.Errorf("a run that did not reach its end must carry no finish, got %s", rec.FinishedAt)
}
if rec.Checked != 0 || rec.Stale != 0 {
t.Errorf("counts = (%d, %d), want both zero at the start", rec.Checked, rec.Stale)
}
if !rec.Running() {
t.Error("a record with a start and no finish must read as a run in progress")
}
}
// TestEnvironmentCheckRecordsItsCompletion: the record the run leaves
// behind carries the finish and the counts the control's toast and the
// resting line report, and they are the counts the run returned.
func TestEnvironmentCheckRecordsItsCompletion(t *testing.T) {
database := testDB(t)
ctx := context.Background()
fx := newCheckFixture(t, ctx, database)
fx.mapFixture(t, ctx, database, "synced")
// Only the fixture's price is missing, so the run reaches its end with
// at least one stale row to count.
t.Cleanup(stripetest.Install(&stripetest.MockBackend{
ObjectErrs: map[string]error{
fx.stripePriceID: &stripego.Error{
Type: stripego.ErrorTypeInvalidRequest, Code: stripego.ErrorCodeResourceMissing, HTTPStatusCode: 404,
},
},
DefaultProduct: &stripego.Product{Livemode: false},
DefaultPrice: &stripego.Price{Livemode: false},
}))
result, err := checkActivities(database).CheckMappingEnvironments(ctx,
stripewf.EnvironmentCheckInput{Trigger: stripewf.EnvironmentCheckTriggerOperator})
if err != nil {
t.Fatalf("check: %v", err)
}
rec := readCheckRecord(t, ctx, database)
if rec.FinishedAt == nil {
t.Fatal("a run that reached its end must record when it finished")
}
if rec.StartedAt == nil {
t.Error("the record must keep the time the run started")
}
if rec.Running() {
t.Error("a finished run must not read as one in progress")
}
if rec.Checked != result.Checked || rec.Stale != result.Stale {
t.Errorf("record counts = (%d, %d), want the run's (%d, %d)",
rec.Checked, rec.Stale, result.Checked, result.Stale)
}
if rec.Checked == 0 {
t.Error("the run read rows back but recorded none")
}
if rec.Stale == 0 {
t.Error("the fixture's missing price must be counted stale in the record")
}
if rec.KeyFingerprint != "fingerprint-under-test" {
t.Errorf("key_fingerprint = %q, want the key the run held", rec.KeyFingerprint)
}
}
@@ -361,10 +361,15 @@ func (a *OutboxActivities) executeCreateStripeCustomer(ctx context.Context, entr
// Write the mapping row.
q := internalstripe.New(a.DB)
// Livemode comes from the customer Stripe just returned, never from the
// API key: the stamp has to be a fact about the object, or a bug in the
// key handling would teach the mapping the wrong environment
// (stripe-environment-stamp D1).
_, err = q.UpsertCustomerMapping(ctx, internalstripe.UpsertCustomerMappingParams{
BillingAccountID: payload.BillingAccountID,
StripeCustomerID: sql.NullString{String: cust.ID, Valid: true},
SyncStatus: "synced",
Livemode: sql.NullBool{Bool: cust.Livemode, Valid: true},
})
if err != nil {
return fmt.Errorf("write customer mapping: %w", err)
@@ -420,10 +425,12 @@ func (a *OutboxActivities) executeCreateStripeProduct(ctx context.Context, entry
}
q := internalstripe.New(a.DB)
// Livemode from the product Stripe returned (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)
@@ -482,10 +489,12 @@ func (a *OutboxActivities) executeCreateStripePrice(ctx context.Context, entry O
return fmt.Errorf("stripe create price: %w", err)
}
// Livemode from the price Stripe returned (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)
@@ -123,6 +123,12 @@ func TestOutboxExecutor_CreateStripeProduct_Success(t *testing.T) {
if !mapping.StripeProductID.Valid {
t.Fatal("expected stripe_product_id to be set")
}
// The stamp is the Risks section's guard against a wrong environment:
// it comes from the object Stripe returned, so it is set whenever the
// create succeeded (stripe-environment-stamp D1).
if !mapping.Livemode.Valid {
t.Error("expected livemode to be recorded from the object Stripe returned")
}
}
// --- Task 5.2: create_stripe_price executor tests ---
@@ -311,6 +317,12 @@ func TestOutboxExecutor_CreateStripePrice_Success(t *testing.T) {
if mapping.SyncStatus != "synced" {
t.Fatalf("expected sync_status 'synced', got %q", mapping.SyncStatus)
}
// The stamp is the Risks section's guard against a wrong environment:
// it comes from the object Stripe returned, so it is set whenever the
// create succeeded (stripe-environment-stamp D1).
if !mapping.Livemode.Valid {
t.Error("expected livemode to be recorded from the object Stripe returned")
}
}
// --- Task 5.3: product.* webhook handler tests ---
@@ -345,7 +357,7 @@ func TestWebhookProcessor_ProductCreated_MappingExists(t *testing.T) {
`INSERT INTO core.webhook_events (provider, provider_event_id, event_type, payload, status)
VALUES ('stripe', $1, 'product.created', $2, 'received')
RETURNING id`,
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s"}`, prodStripeID)),
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s", "livemode": false}`, prodStripeID)),
).Scan(&evtID)
if err != nil {
t.Fatalf("insert webhook event: %v", err)
@@ -394,7 +406,7 @@ func TestWebhookProcessor_ProductCreated_Race(t *testing.T) {
`INSERT INTO core.webhook_events (provider, provider_event_id, event_type, payload, status)
VALUES ('stripe', $1, 'product.created', $2, 'received')
RETURNING id`,
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s"}`, prodStripeID)),
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s", "livemode": false}`, prodStripeID)),
).Scan(&evtID)
if err != nil {
t.Fatalf("insert webhook event: %v", err)
@@ -468,7 +480,7 @@ func TestWebhookProcessor_ProductUpdated(t *testing.T) {
`INSERT INTO core.webhook_events (provider, provider_event_id, event_type, payload, status)
VALUES ('stripe', $1, 'product.updated', $2, 'received')
RETURNING id`,
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s"}`, prodStripeID)),
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s", "livemode": false}`, prodStripeID)),
).Scan(&evtID)
if err != nil {
t.Fatalf("insert webhook event: %v", err)
@@ -497,6 +509,11 @@ func TestWebhookProcessor_ProductUpdated(t *testing.T) {
if mapping.SyncStatus != "synced" {
t.Fatalf("expected sync_status 'synced', got %q", mapping.SyncStatus)
}
// The mapping was seeded unverified; the event's own object says which
// environment made the id (stripe-environment-stamp D1).
if !mapping.Livemode.Valid || mapping.Livemode.Bool {
t.Errorf("livemode after the update = %+v, want a recorded test mode", mapping.Livemode)
}
}
func TestWebhookProcessor_ProductDeleted(t *testing.T) {
@@ -529,7 +546,7 @@ func TestWebhookProcessor_ProductDeleted(t *testing.T) {
`INSERT INTO core.webhook_events (provider, provider_event_id, event_type, payload, status)
VALUES ('stripe', $1, 'product.deleted', $2, 'received')
RETURNING id`,
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s"}`, prodStripeID)),
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s", "livemode": false}`, prodStripeID)),
).Scan(&evtID)
if err != nil {
t.Fatalf("insert webhook event: %v", err)
@@ -604,7 +621,7 @@ func TestWebhookProcessor_PriceCreated_MappingExists(t *testing.T) {
`INSERT INTO core.webhook_events (provider, provider_event_id, event_type, payload, status)
VALUES ('stripe', $1, 'price.created', $2, 'received')
RETURNING id`,
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s", "active": true}`, priceStripeID)),
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s", "active": true, "livemode": false}`, priceStripeID)),
).Scan(&evtID)
if err != nil {
t.Fatalf("insert webhook event: %v", err)
@@ -652,7 +669,7 @@ func TestWebhookProcessor_PriceCreated_Race(t *testing.T) {
`INSERT INTO core.webhook_events (provider, provider_event_id, event_type, payload, status)
VALUES ('stripe', $1, 'price.created', $2, 'received')
RETURNING id`,
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s", "active": true}`, priceStripeID)),
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s", "active": true, "livemode": false}`, priceStripeID)),
).Scan(&evtID)
if err != nil {
t.Fatalf("insert webhook event: %v", err)
@@ -737,7 +754,7 @@ func TestWebhookProcessor_PriceUpdated_ActiveFalse(t *testing.T) {
`INSERT INTO core.webhook_events (provider, provider_event_id, event_type, payload, status)
VALUES ('stripe', $1, 'price.updated', $2, 'received')
RETURNING id`,
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s", "active": false}`, priceStripeID)),
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s", "active": false, "livemode": false}`, priceStripeID)),
).Scan(&evtID)
if err != nil {
t.Fatalf("insert webhook event: %v", err)
@@ -809,7 +826,7 @@ func TestWebhookProcessor_PriceUpdated_ActiveTrue(t *testing.T) {
`INSERT INTO core.webhook_events (provider, provider_event_id, event_type, payload, status)
VALUES ('stripe', $1, 'price.updated', $2, 'received')
RETURNING id`,
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s", "active": true}`, priceStripeID)),
evtProviderID, json.RawMessage(fmt.Sprintf(`{"id": "%s", "active": true, "livemode": false}`, priceStripeID)),
).Scan(&evtID)
if err != nil {
t.Fatalf("insert webhook event: %v", err)
@@ -838,6 +855,11 @@ func TestWebhookProcessor_PriceUpdated_ActiveTrue(t *testing.T) {
if mapping.SyncStatus != "synced" {
t.Fatalf("expected sync_status 'synced', got %q", mapping.SyncStatus)
}
// The mapping was seeded unverified; the event's own object says which
// environment made the id (stripe-environment-stamp D1).
if !mapping.Livemode.Valid || mapping.Livemode.Bool {
t.Errorf("livemode after the update = %+v, want a recorded test mode", mapping.Livemode)
}
}
// --- Constraint violation tests ---
@@ -311,6 +311,11 @@ func (a *WebhookActivities) dispatchEvent(ctx context.Context, evt WebhookEvent)
// webhookCustomerPayload represents the scrubbed payload for customer events.
type webhookCustomerPayload struct {
ID string `json:"id"` // Stripe Customer ID (cus_…)
// Livemode is the object's own flag, the environment the id lives in
// (stripe-environment-stamp D1). It is read from the object inside the
// stored payload, not from the event envelope; every Stripe object
// carries it, so a parsed payload always has a real answer here.
Livemode bool `json:"livemode"`
}
// handleCustomerEvent processes customer.created, customer.updated, customer.deleted events.
@@ -350,10 +355,17 @@ func (a *WebhookActivities) handleCustomerEvent(ctx context.Context, evt Webhook
// We need the billing_account_id — look up by stripe_customer_id first.
existing, err := q.GetCustomerMappingByStripeCustomerID(ctx, sql.NullString{String: stripeCustomerID, Valid: true})
if err == nil {
// Mapping exists — just touch updated_at
if err := q.UpdateCustomerMappingSyncStatus(ctx, internalstripe.UpdateCustomerMappingSyncStatusParams{
// The upsert rather than UpdateCustomerMappingSyncStatus: same
// row, same status, same touched updated_at, and it can carry
// the environment the parsed customer reports
// (stripe-environment-stamp D1). The status-only update has no
// column for the flag, so a mapping whose first news of Stripe
// is customer.created would stay unstamped.
if _, err := q.UpsertCustomerMapping(ctx, internalstripe.UpsertCustomerMappingParams{
BillingAccountID: existing.BillingAccountID,
StripeCustomerID: sql.NullString{String: stripeCustomerID, Valid: true},
SyncStatus: "synced",
Livemode: sql.NullBool{Bool: payload.Livemode, Valid: true},
}); err != nil {
return "", fmt.Errorf("update existing mapping: %w", err)
}
@@ -377,9 +389,15 @@ func (a *WebhookActivities) handleCustomerEvent(ctx context.Context, evt Webhook
} else if err != nil {
return "", fmt.Errorf("lookup mapping: %w", err)
}
if err := q.UpdateCustomerMappingSyncStatus(ctx, internalstripe.UpdateCustomerMappingSyncStatusParams{
// The upsert in place of UpdateCustomerMappingSyncStatus: same row,
// same status, same touched updated_at, and it can carry the
// environment the parsed customer reports
// (stripe-environment-stamp D1).
if _, err := q.UpsertCustomerMapping(ctx, internalstripe.UpsertCustomerMappingParams{
BillingAccountID: existing.BillingAccountID,
StripeCustomerID: existing.StripeCustomerID,
SyncStatus: existing.SyncStatus,
Livemode: sql.NullBool{Bool: payload.Livemode, Valid: true},
}); err != nil {
return "", fmt.Errorf("update mapping timestamp: %w", err)
}
@@ -415,6 +433,11 @@ func (a *WebhookActivities) handleCustomerEvent(ctx context.Context, evt Webhook
// webhookProductPayload represents the scrubbed payload for product events.
type webhookProductPayload struct {
ID string `json:"id"` // Stripe Product ID (prod_…)
// Livemode is the object's own flag, the environment the id lives in
// (stripe-environment-stamp D1). It is read from the object inside the
// stored payload, not from the event envelope; every Stripe object
// carries it, so a parsed payload always has a real answer here.
Livemode bool `json:"livemode"`
}
// handleProductEvent processes product.created, product.updated, product.deleted events.
@@ -450,11 +473,15 @@ func (a *WebhookActivities) handleProductEvent(ctx context.Context, evt WebhookE
case "product.created":
existing, err := q.GetProductMappingByStripeID(ctx, sql.NullString{String: stripeProductID, Valid: true})
if err == nil {
// Mapping exists — touch updated_at by re-upserting with same status.
// The mapping exists, so re-upsert with the same status to
// touch updated_at, and stamp the environment the parsed product
// reports (stripe-environment-stamp D1), the same flag the
// updated branch writes.
_, err = q.UpsertProductMapping(ctx, internalstripe.UpsertProductMappingParams{
ProductID: existing.ProductID,
StripeProductID: sql.NullString{String: stripeProductID, Valid: true},
SyncStatus: "synced",
Livemode: sql.NullBool{Bool: payload.Livemode, Valid: true},
})
if err != nil {
return "", fmt.Errorf("update existing product mapping: %w", err)
@@ -481,6 +508,7 @@ func (a *WebhookActivities) handleProductEvent(ctx context.Context, evt WebhookE
ProductID: existing.ProductID,
StripeProductID: existing.StripeProductID,
SyncStatus: existing.SyncStatus,
Livemode: sql.NullBool{Bool: payload.Livemode, Valid: true},
})
if err != nil {
return "", fmt.Errorf("update product mapping timestamp: %w", err)
@@ -515,6 +543,11 @@ func (a *WebhookActivities) handleProductEvent(ctx context.Context, evt WebhookE
type webhookPricePayload struct {
ID string `json:"id"` // Stripe Price ID (price_…)
Active bool `json:"active"` // Whether the price is active
// Livemode is the object's own flag, the environment the id lives in
// (stripe-environment-stamp D1). It is read from the object inside the
// stored payload, not from the event envelope; every Stripe object
// carries it, so a parsed payload always has a real answer here.
Livemode bool `json:"livemode"`
}
// handlePriceEvent processes price.created and price.updated events.
@@ -550,11 +583,15 @@ func (a *WebhookActivities) handlePriceEvent(ctx context.Context, evt WebhookEve
case "price.created":
existing, err := q.GetPriceMappingByStripeID(ctx, sql.NullString{String: stripePriceID, Valid: true})
if err == nil {
// Mapping exists — touch updated_at by re-upserting with same status.
// The mapping exists, so re-upsert with the same status to
// touch updated_at, and stamp the environment the parsed price
// reports (stripe-environment-stamp D1), the same flag the
// updated branch writes.
_, err = q.UpsertPriceMapping(ctx, internalstripe.UpsertPriceMappingParams{
PriceID: existing.PriceID,
StripePriceID: sql.NullString{String: stripePriceID, Valid: true},
SyncStatus: "synced",
Livemode: sql.NullBool{Bool: payload.Livemode, Valid: true},
})
if err != nil {
return "", fmt.Errorf("update existing price mapping: %w", err)
@@ -589,6 +626,7 @@ func (a *WebhookActivities) handlePriceEvent(ctx context.Context, evt WebhookEve
PriceID: existing.PriceID,
StripePriceID: existing.StripePriceID,
SyncStatus: existing.SyncStatus,
Livemode: sql.NullBool{Bool: payload.Livemode, Valid: true},
})
if err != nil {
return "", fmt.Errorf("update price mapping timestamp: %w", err)
@@ -129,3 +129,60 @@ func TestSweepSelectsOnlyUnfinishedStripeRows(t *testing.T) {
}
}
}
// TestProductCreatedStampsTheMapping pins the created branch of design D1.
// product.created reaches a mapping the outbox wrote before the stamp
// existed, so the branch must write the environment the parsed product
// reports, not leave the row unverified; replaying the same event keeps the
// flag, because the upsert's COALESCE takes the offered flag over the
// stored one only when a caller has one to offer.
func TestProductCreatedStampsTheMapping(t *testing.T) {
db := testDB(t)
ctx := context.Background()
acts := NewWebhookActivities(db, slog.New(slog.NewTextHandler(io.Discard, nil)))
var productID string
if err := db.QueryRowContext(ctx,
`INSERT INTO core.products (name, display_category, is_active)
VALUES ($1, NULL, TRUE) RETURNING product_id`,
"envstamp-product-"+uuid.New().String()[:8],
).Scan(&productID); err != nil {
t.Fatalf("create product: %v", err)
}
stripeProductID := "prod_envstamp_" + uuid.New().String()[:12]
if _, err := db.ExecContext(ctx,
`INSERT INTO stripe.product_mappings (product_id, stripe_product_id, sync_status, livemode)
VALUES ($1, $2, 'synced', NULL)`,
productID, stripeProductID,
); err != nil {
t.Fatalf("seed unstamped product mapping: %v", err)
}
payload := `{"id": "` + stripeProductID + `", "livemode": true}`
evt := insertEventRow(t, db, "product.created", payload)
if _, err := acts.handleProductEvent(ctx, evt); err != nil {
t.Fatalf("handleProductEvent: %v", err)
}
if flag := productMappingLivemode(t, ctx, db, productID); !flag.Valid || !flag.Bool {
t.Fatalf("after product.created, livemode = %+v, want true", flag)
}
replay := insertEventRow(t, db, "product.created", payload)
if _, err := acts.handleProductEvent(ctx, replay); err != nil {
t.Fatalf("handleProductEvent replay: %v", err)
}
if flag := productMappingLivemode(t, ctx, db, productID); !flag.Valid || !flag.Bool {
t.Fatalf("after replay, livemode = %+v, want true", flag)
}
}
func productMappingLivemode(t *testing.T, ctx context.Context, db *sql.DB, productID string) sql.NullBool {
t.Helper()
var flag sql.NullBool
if err := db.QueryRowContext(ctx,
`SELECT livemode FROM stripe.product_mappings WHERE product_id = $1`, productID,
).Scan(&flag); err != nil {
t.Fatalf("read product mapping livemode: %v", err)
}
return flag
}
@@ -42,6 +42,12 @@ type webhookInvoicePayload struct {
// survives ScrubPII unchanged.
Number string `json:"number"`
Lines invoicePayloadLines `json:"lines"`
// Livemode is the invoice object's own flag, the environment the id
// lives in (stripe-environment-stamp D1). It comes from the object
// inside the stored payload, not from the event envelope, which this
// activity never sees; every Stripe object carries it, so a parsed
// payload always has a real answer here.
Livemode bool `json:"livemode"`
}
type invoicePayloadLines struct {
@@ -244,6 +250,7 @@ func (a *WebhookActivities) handleInvoiceFinalized(ctx context.Context, evt Webh
StripeInvoiceID: sql.NullString{String: inv.ID, Valid: true},
StripeInvoiceNumber: sql.NullString{String: inv.Number, Valid: inv.Number != ""},
SyncStatus: "synced",
Livemode: sql.NullBool{Bool: inv.Livemode, Valid: true},
})
if err != nil {
return "", fmt.Errorf("insert invoice mapping: %w", err)
@@ -361,10 +368,13 @@ func (a *WebhookActivities) handleInvoicePaid(ctx context.Context, evt WebhookEv
// Insert payment mapping.
if inv.PaymentIntent != "" {
// The payment intent belongs to the invoice, so it lives in the
// invoice's environment (stripe-environment-stamp D1).
_, err = txStripeQ.InsertPaymentMapping(ctx, internalstripe.InsertPaymentMappingParams{
PaymentID: payment.PaymentID,
StripePaymentIntentID: sql.NullString{String: inv.PaymentIntent, Valid: true},
SyncStatus: "synced",
Livemode: sql.NullBool{Bool: inv.Livemode, Valid: true},
})
if err != nil {
return "", fmt.Errorf("insert payment mapping: %w", err)
@@ -437,10 +447,13 @@ func (a *WebhookActivities) handleInvoicePaymentFailed(ctx context.Context, evt
}
if inv.PaymentIntent != "" {
// The payment intent belongs to the invoice, so it lives in the
// invoice's environment (stripe-environment-stamp D1).
_, err = txStripeQ.InsertPaymentMapping(ctx, internalstripe.InsertPaymentMappingParams{
PaymentID: payment.PaymentID,
StripePaymentIntentID: sql.NullString{String: inv.PaymentIntent, Valid: true},
SyncStatus: "synced",
Livemode: sql.NullBool{Bool: inv.Livemode, Valid: true},
})
if err != nil {
return "", fmt.Errorf("insert payment mapping: %w", err)
@@ -4,13 +4,20 @@
package workflows
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"math"
"math/rand"
"os"
"strconv"
"strings"
"testing"
"time"
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"go.temporal.io/sdk/temporal"
)
@@ -124,6 +131,7 @@ func TestInvoicePayloadParsing(t *testing.T) {
"due_date": 0,
"payment_intent": "",
"default_payment_method": "",
"livemode": true,
"lines": {
"data": [
{
@@ -170,6 +178,12 @@ func TestInvoicePayloadParsing(t *testing.T) {
if li.Description != "Monthly membership" {
t.Errorf("line item Description = %q, want %q", li.Description, "Monthly membership")
}
// The environment stamp the invoice and payment mapping writers read
// (stripe-environment-stamp D1) comes from the invoice object's own
// flag, not from the event envelope this activity never sees.
if !payload.Livemode {
t.Error("Livemode = false, want true from the payload's own livemode")
}
}
func TestInvoicePayloadParsing_NullableFields(t *testing.T) {
@@ -180,6 +194,7 @@ func TestInvoicePayloadParsing_NullableFields(t *testing.T) {
"amount_due": 500,
"amount_paid": 0,
"currency": "gbp",
"livemode": false,
"lines": {"data": []}
}`
@@ -233,3 +248,139 @@ func TestHandleInvoiceVoided_NoMapping_IsNoOp(t *testing.T) {
_ = db
t.Log("invoice.voided no-op for missing mapping — structure verified")
}
// seedEnvStampFixture creates the org, the billing account and the Stripe
// customer mapping the invoice and payment-method projections resolve a
// payload through, and returns the billing account id and the Stripe
// customer id. The customer mapping is left unstamped, so nothing but the
// handler under test can have written the flag the assertions read.
func seedEnvStampFixture(t *testing.T, ctx context.Context, database *sql.DB) (baID, custStripeID string) {
t.Helper()
suffix := fmt.Sprintf("%d", rand.Int63())
var userID string
if err := database.QueryRowContext(ctx,
`INSERT INTO core.users (oidc_subject, status) VALUES ($1, 'active') RETURNING user_id`,
"envstamp-"+suffix,
).Scan(&userID); err != nil {
t.Fatalf("create user: %v", err)
}
var personID string
if err := database.QueryRowContext(ctx,
`INSERT INTO core.persons (user_id, display_name, primary_email)
VALUES ($1, 'Env Stamp', $2) RETURNING person_id`,
userID, "envstamp-"+suffix+"@example.com",
).Scan(&personID); err != nil {
t.Fatalf("create person: %v", err)
}
var orgID string
if err := database.QueryRowContext(ctx,
`INSERT INTO core.organizations (name, org_type, owner_person_id, status)
VALUES ($1, 'personal', $2, 'active') RETURNING org_id`,
"envstamp-org-"+suffix, personID,
).Scan(&orgID); err != nil {
t.Fatalf("create org: %v", err)
}
if err := database.QueryRowContext(ctx,
`INSERT INTO core.accounts (org_id, name, status, metadata)
VALUES ($1, 'Env Stamp Account', 'active', '{}'::jsonb) RETURNING billing_account_id`,
orgID,
).Scan(&baID); err != nil {
t.Fatalf("create billing account: %v", err)
}
custStripeID = "cus_envstamp_" + suffix
if _, err := internalstripe.New(database).UpsertCustomerMapping(ctx, internalstripe.UpsertCustomerMappingParams{
BillingAccountID: baID,
StripeCustomerID: sql.NullString{String: custStripeID, Valid: true},
SyncStatus: "synced",
}); err != nil {
t.Fatalf("upsert customer mapping: %v", err)
}
return baID, custStripeID
}
// mappingLivemode reads one mapping row's livemode column by the Stripe id
// it records. table and column are literals from the call sites, never
// request data.
func mappingLivemode(t *testing.T, ctx context.Context, database *sql.DB, table, column, stripeID string) sql.NullBool {
t.Helper()
var flag sql.NullBool
q := fmt.Sprintf(`SELECT livemode FROM stripe.%s WHERE %s = $1`, table, column)
if err := database.QueryRowContext(ctx, q, stripeID).Scan(&flag); err != nil {
t.Fatalf("read %s.livemode: %v", table, err)
}
return flag
}
// TestHandleInvoiceFinalized_StampsEnvironment pins the projection write
// design D1 names: the invoice mapping records the environment the invoice
// object itself reported, so the billing views can hide it when the key
// moves. The payload says livemode true under a fixture that stamps
// nothing, so a true flag can only have come from the handler.
func TestHandleInvoiceFinalized_StampsEnvironment(t *testing.T) {
database := testDB(t)
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
_, custStripeID := seedEnvStampFixture(t, ctx, database)
invStripeID := fmt.Sprintf("in_envstamp_%d", rand.Int63())
acts := NewWebhookActivities(database, logger)
evt := WebhookEvent{Provider: "stripe", ProviderEventID: invStripeID, EventType: "invoice.finalized"}
inv := webhookInvoicePayload{
ID: invStripeID, Customer: custStripeID, Currency: "usd",
AmountDue: 2500, Livemode: true,
}
if _, err := acts.handleInvoiceFinalized(ctx, evt, inv, internalstripe.New(database)); err != nil {
t.Fatalf("handleInvoiceFinalized: %v", err)
}
flag := mappingLivemode(t, ctx, database, "invoice_mappings", "stripe_invoice_id", invStripeID)
if !flag.Valid || !flag.Bool {
t.Fatalf("invoice_mappings.livemode = %+v, want true", flag)
}
}
// TestHandleInvoicePaid_StampsPaymentEnvironment pins the second projection
// write of design D1: the payment intent belongs to the invoice, so the
// payment mapping takes the invoice object's flag. The invoice here is
// finalized in test mode and paid in test mode, the case a live key must
// hide from the payments view.
func TestHandleInvoicePaid_StampsPaymentEnvironment(t *testing.T) {
database := testDB(t)
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
_, custStripeID := seedEnvStampFixture(t, ctx, database)
suffix := rand.Int63()
invStripeID := fmt.Sprintf("in_envstamp_paid_%d", suffix)
intentID := fmt.Sprintf("pi_envstamp_%d", suffix)
acts := NewWebhookActivities(database, logger)
stripeQ := internalstripe.New(database)
finalized := webhookInvoicePayload{
ID: invStripeID, Customer: custStripeID, Currency: "usd",
AmountDue: 2500, Livemode: false,
}
evt := WebhookEvent{Provider: "stripe", ProviderEventID: invStripeID, EventType: "invoice.finalized"}
if _, err := acts.handleInvoiceFinalized(ctx, evt, finalized, stripeQ); err != nil {
t.Fatalf("handleInvoiceFinalized: %v", err)
}
paid := finalized
paid.AmountPaid = 2500
paid.PaymentIntent = intentID
evt.EventType = "invoice.paid"
if _, err := acts.handleInvoicePaid(ctx, evt, paid, stripeQ); err != nil {
t.Fatalf("handleInvoicePaid: %v", err)
}
flag := mappingLivemode(t, ctx, database, "payment_mappings", "stripe_payment_intent_id", intentID)
if !flag.Valid || flag.Bool {
t.Fatalf("payment_mappings.livemode = %+v, want false", flag)
}
invFlag := mappingLivemode(t, ctx, database, "invoice_mappings", "stripe_invoice_id", invStripeID)
if !invFlag.Valid || invFlag.Bool {
t.Fatalf("invoice_mappings.livemode = %+v, want false", invFlag)
}
}
@@ -20,6 +20,12 @@ type webhookPaymentMethodPayload struct {
Customer string `json:"customer"`
Type string `json:"type"`
Card *paymentMethodCard `json:"card"`
// Livemode is the payment method object's own flag, the environment the
// id lives in (stripe-environment-stamp D1). It comes from the object
// inside the stored payload, not from the event envelope, which this
// activity never sees; every Stripe object carries it, so a parsed
// payload always has a real answer here.
Livemode bool `json:"livemode"`
}
// paymentMethodCard holds the safe descriptor fields from Stripe's card object.
@@ -123,6 +129,7 @@ func (a *WebhookActivities) handlePaymentMethodAttached(ctx context.Context, evt
PaymentMethodID: newPM.PaymentMethodID,
StripePaymentMethodID: sql.NullString{String: pm.ID, Valid: true},
SyncStatus: "synced",
Livemode: sql.NullBool{Bool: pm.Livemode, Valid: true},
})
if err != nil {
return "", fmt.Errorf("upsert payment method mapping: %w", err)
@@ -4,9 +4,16 @@
package workflows
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"math/rand"
"os"
"testing"
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
)
func TestPaymentMethodPayloadParsing(t *testing.T) {
@@ -14,6 +21,7 @@ func TestPaymentMethodPayloadParsing(t *testing.T) {
"id": "pm_test123",
"customer": "cus_test456",
"type": "card",
"livemode": true,
"card": {
"brand": "visa",
"last4": "4242",
@@ -52,11 +60,17 @@ func TestPaymentMethodPayloadParsing(t *testing.T) {
if payload.Card.Funding != "credit" {
t.Errorf("Card.Funding = %q, want %q", payload.Card.Funding, "credit")
}
// The environment stamp the mapping writer reads
// (stripe-environment-stamp D1) comes from the object's own flag, not
// from the event envelope this activity never sees.
if !payload.Livemode {
t.Error("Livemode = false, want true from the payload's own livemode")
}
}
func TestPaymentMethodPayloadParsing_NilCard(t *testing.T) {
// Non-card payment method (e.g., sepa_debit) may not have a card object.
raw := `{"id": "pm_sepa", "customer": "cus_test", "type": "sepa_debit"}`
raw := `{"id": "pm_sepa", "customer": "cus_test", "type": "sepa_debit", "livemode": false}`
var payload webhookPaymentMethodPayload
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
@@ -138,3 +152,30 @@ func TestHandlePaymentMethodUpdated_NoMapping_IsNoOp(t *testing.T) {
_ = db
t.Log("payment_method.updated no-op for missing mapping — structure verified")
}
// TestHandlePaymentMethodAttached_StampsEnvironment pins the third
// projection write of design D1: the payment method mapping records the
// environment the payment method object reported, which is what keeps a
// test-mode card off a live key's billing views.
func TestHandlePaymentMethodAttached_StampsEnvironment(t *testing.T) {
database := testDB(t)
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
_, custStripeID := seedEnvStampFixture(t, ctx, database)
pmStripeID := fmt.Sprintf("pm_envstamp_%d", rand.Int63())
acts := NewWebhookActivities(database, logger)
pm := webhookPaymentMethodPayload{
ID: pmStripeID, Customer: custStripeID, Type: "card", Livemode: true,
Card: &paymentMethodCard{Brand: "visa", Last4: "4242", ExpMonth: 12, ExpYear: 2030, Funding: "credit"},
}
evt := WebhookEvent{Provider: "stripe", ProviderEventID: pmStripeID, EventType: "payment_method.attached"}
if _, err := acts.handlePaymentMethodAttached(ctx, evt, pm, internalstripe.New(database)); err != nil {
t.Fatalf("handlePaymentMethodAttached: %v", err)
}
flag := mappingLivemode(t, ctx, database, "payment_method_mappings", "stripe_payment_method_id", pmStripeID)
if !flag.Valid || !flag.Bool {
t.Fatalf("payment_method_mappings.livemode = %+v, want true", flag)
}
}
+3 -1
View File
@@ -47,7 +47,9 @@ func init() {
// Operator: the product's prices and its Stripe sync.
{Label: "Sync to Stripe", Method: "POST", Path: "/partials/operator/products/{productID}/sync-stripe",
Note: "fired from the price row's Sync, the readiness panel's Sync to Stripe, and its Retry"},
Note: "fired from the price row's Sync, the readiness panel's Sync to Stripe (labelled Create in live or Create in test when the recorded mapping is one the key cannot reach), and its Retry"},
{Label: "Check now", Method: "POST", Path: "/partials/operator/integrations/stripe/environment-check",
Note: "the Stripe provider page's Environment check section; starts the read-back boot starts, under the same workflow id"},
{Label: "Make default", Method: "POST", Path: "/partials/operator/products/{productID}/prices/{priceID}/make-default"},
{Label: "Deactivate price", Method: "POST", Path: "/partials/operator/products/{productID}/prices/{priceID}/deactivate", Modal: true},
+25 -5
View File
@@ -241,9 +241,15 @@ var badgeMap = map[string]Badge{
"revoked": {Label: "Revoked", Tone: "secondary"},
"superseded": {Label: "Superseded", Tone: "secondary"},
"live": {Label: "Live", Tone: "success"},
"draft": {Label: "Draft", Tone: "secondary"},
"published": {Label: "Published", Tone: "success"},
"retired": {Label: "Retired", Tone: "secondary"},
// The counterpart of "live" for the Stripe environment a row's mapping
// records (stripe-environment-stamp D7). Secondary, the muted tone the
// map already uses for a state that is not the live one: a test-mode
// row is not a problem, it is simply not the environment this key is
// in, and the billing views badge it only while showing both.
"test": {Label: "Test", Tone: "secondary"},
"draft": {Label: "Draft", Tone: "secondary"},
"published": {Label: "Published", Tone: "success"},
"retired": {Label: "Retired", Tone: "secondary"},
// Product purchasability verdict (product-management "The Status
// column is the verdict", design D1); draft and retired reuse the
// Lifecycle states just above rather than duplicating them.
@@ -300,7 +306,12 @@ var badgeMap = map[string]Badge{
"synced": {Label: "Synced", Tone: "success"},
"not_mapped": {Label: "Not mapped", Tone: "secondary"},
"sync_pending": {Label: "Sync pending", Tone: "warning"},
"sync_failed": {Label: "Sync failed", Tone: "danger"},
// A mapping whose Stripe id the environment check could not fetch under
// the current key (stripe-environment-stamp D2). The object still
// exists in the environment that made it, so this is not a failure of
// the sync; it is an id this deployment can no longer reach.
"stale": {Label: "Stale", Tone: "warning"},
"sync_failed": {Label: "Sync failed", Tone: "danger"},
}
// StatusBadge maps a state to its badge. An unknown state renders as a
@@ -328,7 +339,7 @@ var knownStates = []string{
"personal", "team", "system", "recurring", "one_time",
"grant_issued", "transition", "invoice_created", "payment_received", "product",
"environment", "override", "default", "subdomain", "custom",
"live", "superseded", "inactive", "synced", "not_mapped", "sync_pending", "sync_failed",
"live", "test", "superseded", "inactive", "synced", "not_mapped", "sync_pending", "sync_failed", "stale",
"purchasable", "ready_to_grant",
"paid", "unpaid", "overdue", "verified", "unverified", "draft", "published", "retired",
"expired", "canceled", "released", "revoked",
@@ -823,6 +834,15 @@ func (v PaymentViewModel) SyncBadge() Badge { return StatusBadge(v.Stri
func (v BillingAccountViewModel) StatusBadge() Badge { return StatusBadge(v.Status) }
func (v BillingAccountViewModel) SyncBadge() Badge { return StatusBadge(v.StripeSyncStatus) }
// EnvBadge marks a row recorded in the Stripe environment the API key is
// not in, rendered only while a billing view is showing both
// (stripe-environment-stamp D7). EnvState is "" on every other row, and
// the templates render nothing for it.
func (v InvoiceViewModel) EnvBadge() Badge { return StatusBadge(v.EnvState) }
func (v SubscriptionViewModel) EnvBadge() Badge { return StatusBadge(v.EnvState) }
func (v PaymentViewModel) EnvBadge() Badge { return StatusBadge(v.EnvState) }
func (v BillingAccountViewModel) EnvBadge() Badge { return StatusBadge(v.EnvState) }
// billingEmpty is the four views' shared empty state: blocked while
// Stripe is not configured; otherwise the plain absence, with a note
// naming the mechanism that would fill it (ACC-1: the affirmative-denial
+40 -3
View File
@@ -117,17 +117,34 @@ func (h *BillingCheckoutHandler) HandleCheckout(w http.ResponseWriter, r *http.R
// Ensure Stripe price mapping exists
stripeQ := internalstripe.New(h.Database)
keyMode := internalstripe.ModeForKey(stripe.Key)
priceMapping, err := stripeQ.GetPriceMappingByPriceID(ctx, priceID)
if err != nil || !priceMapping.StripePriceID.Valid {
h.Logger.Error("stripe price mapping not found", slog.String("price_id", priceID))
http.Error(w, "price not yet available in Stripe", http.StatusBadRequest)
return
}
// A price id recorded in the environment this key is not in, or one the
// environment check could not fetch, reaches Stripe as resource_missing
// and the member reads "failed to start checkout". It is the same fact
// as an unmapped price for everyone who cares, so it is refused here,
// before any Stripe call, with the same message
// (stripe-environment-stamp D5).
if !internalstripe.MappingReachable(priceMapping.Livemode, priceMapping.SyncStatus, keyMode) {
h.Logger.Info("checkout refused: price mapping is out of reach under the current key",
slog.String("price_id", priceID),
slog.String("stripe_price_id", priceMapping.StripePriceID.String),
slog.String("recorded_mode", internalstripe.RecordedMode(priceMapping.Livemode, priceMapping.SyncStatus)),
slog.String("key_mode", keyMode))
http.Error(w, "price not yet available in Stripe", http.StatusBadRequest)
return
}
// Ensure Stripe customer exists (create synchronously if missing)
customerMapping, err := stripeQ.GetCustomerMappingByBillingAccountID(ctx, account.BillingAccountID)
var stripeCustomerID string
if err == sql.ErrNoRows {
switch {
case err == sql.ErrNoRows:
// Create Stripe customer synchronously
stripeCustomerID, err = h.createStripeCustomer(ctx, account, stripeQ)
if err != nil {
@@ -135,11 +152,27 @@ func (h *BillingCheckoutHandler) HandleCheckout(w http.ResponseWriter, r *http.R
http.Error(w, "failed to set up billing", http.StatusInternalServerError)
return
}
} else if err != nil {
case err != nil:
h.Logger.Error("failed to check customer mapping", slog.Any("error", err))
http.Error(w, "internal error", http.StatusInternalServerError)
return
} else {
// A customer id this key cannot reach is treated as no customer at all:
// creating one again in the current environment is what a deployment
// that has moved needs, and the new id replaces the old one on the
// mapping (stripe-environment-stamp D5).
case !internalstripe.MappingReachable(customerMapping.Livemode, customerMapping.SyncStatus, keyMode):
h.Logger.Info("checkout: customer mapping is out of reach under the current key; creating the customer again",
slog.String("billing_account_id", account.BillingAccountID),
slog.String("stripe_customer_id", customerMapping.StripeCustomerID.String),
slog.String("recorded_mode", internalstripe.RecordedMode(customerMapping.Livemode, customerMapping.SyncStatus)),
slog.String("key_mode", keyMode))
stripeCustomerID, err = h.createStripeCustomer(ctx, account, stripeQ)
if err != nil {
h.Logger.Error("failed to create stripe customer", slog.Any("error", err))
http.Error(w, "failed to set up billing", http.StatusInternalServerError)
return
}
default:
if !customerMapping.StripeCustomerID.Valid || customerMapping.SyncStatus != "synced" {
h.Logger.Error("customer mapping not synced", slog.String("billing_account_id", account.BillingAccountID))
http.Error(w, "billing setup in progress; try again shortly", http.StatusServiceUnavailable)
@@ -201,10 +234,14 @@ func (h *BillingCheckoutHandler) createStripeCustomer(ctx context.Context, accou
return "", fmt.Errorf("create stripe customer: %w", err)
}
// The environment stamp comes off the customer Stripe just returned,
// never off the key, so a mapping written here records the environment
// that actually made the id (stripe-environment-stamp D1).
_, err = stripeQ.UpsertCustomerMapping(ctx, internalstripe.UpsertCustomerMappingParams{
BillingAccountID: account.BillingAccountID,
StripeCustomerID: sql.NullString{String: cust.ID, Valid: true},
SyncStatus: "synced",
Livemode: sql.NullBool{Bool: cust.Livemode, Valid: true},
})
if err != nil {
return "", fmt.Errorf("write customer mapping: %w", err)
@@ -0,0 +1,278 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server_test
// Checkout's environment gate (stripe-environment-stamp D5, tasks 3.6,
// 3.7 and 3.10): a price whose mapping records the environment the API
// key is not in, or one the environment check marked stale, is refused
// with the message an unmapped price is refused with, before any Stripe
// call; a customer mapping in the same state is treated as absent so the
// customer is created again; and an unverified mapping changes nothing.
//
// DB-backed via TEST_DATABASE_URL, offline via stripetest's in-process
// backend. These tests mutate global stripe-go state (the key and the
// backend), so they do not run in parallel.
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/alexedwards/scs/v2"
stripe "github.com/stripe/stripe-go/v81"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
"git.coopcloud.tech/wiki-cafe/member-console/internal/stripetest"
)
// checkoutEnv is one seeded org with a billing account, a published
// product, an active price, and whatever mappings a case wants.
type checkoutEnv struct {
t *testing.T
database *sql.DB
handler *server.BillingCheckoutHandler
session context.Context
orgID string
accountID string
priceID string
}
// newCheckoutEnv seeds the fixture and points stripe-go at a backend that
// fails every call, so a test that expects a refusal before the Stripe
// call gets a distinguishable answer if the refusal ever stops firing.
func newCheckoutEnv(t *testing.T, keyPrefix string) *checkoutEnv {
t.Helper()
database := testDB(t)
ctx := context.Background()
t.Cleanup(stripetest.Install(&stripetest.MockBackend{Err: errors.New("stripetest: no call expected")}))
prevKey := stripe.Key
stripe.Key = keyPrefix + "checkoutenv"
t.Cleanup(func() { stripe.Key = prevKey })
marker := fmt.Sprintf("ce%d", time.Now().UnixNano())
scan := func(query string, args ...any) string {
var id string
if err := database.QueryRowContext(ctx, query, args...).Scan(&id); err != nil {
t.Fatalf("fixture %q: %v", query, err)
}
return id
}
userID := scan(`INSERT INTO core.users (oidc_subject) VALUES ($1) RETURNING user_id`, "sub-"+marker)
personID := scan(`INSERT INTO core.persons (user_id, display_name, primary_email) VALUES ($1,$2,$3) RETURNING person_id`,
userID, "Checkout Env "+marker, marker+"@example.test")
// core.org_types is seeded per deployment, so the fixture mints its own
// rather than assuming a vocabulary the shared test database holds.
orgType := "ce" + marker[len(marker)-12:]
if _, err := database.ExecContext(ctx,
`INSERT INTO core.org_types (org_type, display_name) VALUES ($1, $1)`, orgType); err != nil {
t.Fatalf("fixture org type: %v", err)
}
orgID := scan(`INSERT INTO core.organizations (name, org_type, owner_person_id) VALUES ($1,$2,$3) RETURNING org_id`,
"Checkout Env "+marker, orgType, personID)
var accountID string
if err := database.QueryRowContext(ctx,
`INSERT INTO core.accounts (org_id, name, status) VALUES ($1, 'Default', 'active') RETURNING billing_account_id`,
orgID).Scan(&accountID); err != nil {
t.Fatalf("fixture billing account: %v", err)
}
var productID string
if err := database.QueryRowContext(ctx,
`INSERT INTO core.products (name, lifecycle_status, is_active, is_public)
VALUES ($1, 'published', true, true) RETURNING product_id`,
"Checkout Env Plan "+marker).Scan(&productID); err != nil {
t.Fatalf("fixture product: %v", err)
}
var priceID string
if err := database.QueryRowContext(ctx,
`INSERT INTO core.prices (product_id, currency, unit_amount, recurring_interval, is_active, is_default)
VALUES ($1, 'usd', 1000, 'month', true, true) RETURNING price_id`,
productID).Scan(&priceID); err != nil {
t.Fatalf("fixture price: %v", err)
}
sm := scs.New()
sctx, err := sm.Load(ctx, "")
if err != nil {
t.Fatalf("session load: %v", err)
}
sm.Put(sctx, "authenticated", true)
sm.Put(sctx, "org_id", orgID)
return &checkoutEnv{
t: t,
database: database,
handler: &server.BillingCheckoutHandler{
Database: database,
BillingQ: billing.New(database),
AuthConfig: &auth.Config{SessionManager: sm},
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
BaseURL: "https://example.test",
},
session: sctx,
orgID: orgID,
accountID: accountID,
priceID: priceID,
}
}
// seedPriceMapping writes the price's mapping with one environment stamp.
// livemode nil leaves the column NULL, the unverified state every row
// carries after the migration.
func (e *checkoutEnv) seedPriceMapping(livemode *bool, syncStatus string) {
e.t.Helper()
if _, err := e.database.ExecContext(context.Background(),
`INSERT INTO stripe.price_mappings (price_id, stripe_price_id, sync_status, livemode)
VALUES ($1, $2, $3, $4)`,
e.priceID, "price_"+e.priceID, syncStatus, nullBool(livemode)); err != nil {
e.t.Fatalf("fixture price mapping: %v", err)
}
}
func (e *checkoutEnv) seedCustomerMapping(livemode *bool, syncStatus string) {
e.t.Helper()
if _, err := e.database.ExecContext(context.Background(),
`INSERT INTO stripe.customer_mappings (billing_account_id, stripe_customer_id, sync_status, livemode)
VALUES ($1, $2, $3, $4)`,
e.accountID, "cus_"+e.accountID, syncStatus, nullBool(livemode)); err != nil {
e.t.Fatalf("fixture customer mapping: %v", err)
}
}
func (e *checkoutEnv) post() (int, string) {
e.t.Helper()
form := url.Values{"price_id": {e.priceID}}
req := httptest.NewRequestWithContext(e.session, http.MethodPost, "/billing/checkout", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
e.handler.HandleCheckout(rec, req)
return rec.Code, rec.Body.String()
}
func nullBool(b *bool) sql.NullBool {
if b == nil {
return sql.NullBool{}
}
return sql.NullBool{Bool: *b, Valid: true}
}
// TestCheckoutRefusesPriceOutsideTheKeyEnvironment pins the
// stripe-subscription-creation scenarios "Checkout refuses a price the key
// cannot reach" and "Checkout proceeds on an unverified price mapping".
func TestCheckoutRefusesPriceOutsideTheKeyEnvironment(t *testing.T) {
live, test := true, false
cases := []struct {
name string
keyPrefix string
livemode *bool
syncStatus string
wantBody string
}{
{
name: "mapping recorded in test under a live key", keyPrefix: "sk_live_",
livemode: &test, syncStatus: "synced",
wantBody: "price not yet available in Stripe",
},
{
name: "mapping recorded in live under a test key", keyPrefix: "sk_test_",
livemode: &live, syncStatus: "synced",
wantBody: "price not yet available in Stripe",
},
{
name: "stale mapping whose stamp still agrees", keyPrefix: "sk_live_",
livemode: &live, syncStatus: "stale",
wantBody: "price not yet available in Stripe",
},
{
// An unverified mapping is not a disagreement, so the price
// gate opens and the request stops at the next precondition,
// the customer mapping this fixture leaves pending.
name: "unverified mapping is not a disagreement", keyPrefix: "sk_live_",
livemode: nil, syncStatus: "synced",
wantBody: "billing setup in progress",
},
{
// A stamp that agrees and has been read back is the settled
// state, and it reaches the same next precondition.
name: "mapping recorded in the key's own environment", keyPrefix: "sk_live_",
livemode: &live, syncStatus: "synced",
wantBody: "billing setup in progress",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
e := newCheckoutEnv(t, tc.keyPrefix)
e.seedPriceMapping(tc.livemode, tc.syncStatus)
// Pending, so a request that clears the price gate stops here
// rather than calling Stripe.
e.seedCustomerMapping(tc.livemode, "pending")
code, body := e.post()
if code == http.StatusOK || code == http.StatusSeeOther {
t.Fatalf("checkout must not have reached Stripe: status %d", code)
}
if !strings.Contains(body, tc.wantBody) {
t.Errorf("body = %q, want it to contain %q", strings.TrimSpace(body), tc.wantBody)
}
})
}
}
// TestCheckoutTreatsUnreachableCustomerMappingAsAbsent pins the
// stripe-subscription-creation scenario "Checkout re-creates a customer the
// key cannot reach": the stored id is not sent, the customer is created
// again. The mock backend fails that create, so the answer distinguishes
// the create attempt (500, "failed to set up billing") from the refusal a
// merely-unsynced mapping gets (503, "billing setup in progress").
func TestCheckoutTreatsUnreachableCustomerMappingAsAbsent(t *testing.T) {
live, test := true, false
cases := []struct {
name string
livemode *bool
syncStatus string
wantCode int
wantBody string
}{
{"recorded in test under a live key", &test, "synced", http.StatusInternalServerError, "failed to set up billing"},
{"stale under a live key", &live, "stale", http.StatusInternalServerError, "failed to set up billing"},
{"unverified", nil, "synced", http.StatusSeeOther, ""},
{"recorded in the key's own environment", &live, "synced", http.StatusSeeOther, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
e := newCheckoutEnv(t, "sk_live_")
e.seedPriceMapping(&live, "synced")
e.seedCustomerMapping(tc.livemode, tc.syncStatus)
code, body := e.post()
if tc.wantBody == "" {
// A reachable, synced mapping is used as it is: the handler
// gets as far as the Checkout Session, which the mock
// backend refuses, so the answer is the session failure.
if strings.Contains(body, "failed to set up billing") {
t.Errorf("a reachable customer mapping must be used, not recreated: %q", strings.TrimSpace(body))
}
return
}
if code != tc.wantCode || !strings.Contains(body, tc.wantBody) {
t.Errorf("status %d body %q, want %d containing %q", code, strings.TrimSpace(body), tc.wantCode, tc.wantBody)
}
})
}
}
+47
View File
@@ -122,6 +122,53 @@ func FetchPage[R any](p *ListParams, load func(limit, offset int32) ([]R, int64,
return rows, total, err
}
// envParam and envAll are the billing views' environment switch
// (stripe-environment-stamp D7). It rides ListNav.Extra rather than the
// facet, because the scaffold carries one facet per list and invoices and
// subscriptions spend theirs on status; Extra is already carried into
// every URL the nav builds and into the search form's hidden inputs, so
// the state survives a search, a facet click, a page and a page-size
// change without any per-list plumbing.
const (
envParam = "env"
envAll = "all"
)
// ParseEnvAll reports whether the request asks a billing view for every
// environment. Any other value of env, including none, means the key's own.
func ParseEnvAll(r *http.Request) bool {
return r.URL.Query().Get(envParam) == envAll
}
// EnvExtra is the Extra a billing ListNav carries so the all state
// survives every link the scaffold builds. It is nil in the ordinary
// state, which keeps the canonical view's URLs canonical.
func EnvExtra(all bool) url.Values {
if !all {
return nil
}
return url.Values{envParam: []string{envAll}}
}
// EnvURL is this list's URL in the other environment state, from page one:
// the absence line's switch. A state change re-windows the whole set, so
// it resets the page the way a facet or page-size change does, and keeps
// the search and the facet.
func (n ListNav) EnvURL(all bool) string {
extra := url.Values{}
for k, vals := range n.Extra {
if k == envParam {
continue
}
extra[k] = vals
}
if all {
extra.Set(envParam, envAll)
}
n.Extra = extra
return n.url(n.Q, n.Facet, 1, n.PerPage)
}
// FacetOption is one value of a list's status filter.
type FacetOption struct {
Value string
@@ -0,0 +1,192 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
// The billing views' environment switch and absence line
// (stripe-environment-stamp D7, tasks 6.4 and 6.5), pinned at the level
// they are decided: the line's exact wording in both states, the fact
// that nothing hidden renders nothing, the URL the switch points at, and
// which rows earn the badge.
import (
"database/sql"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// billingNav is one of the four billing views' navs, in the state a
// request would leave it: a search, a status facet, page two, and the
// environment switch carried on Extra.
func billingNav(all bool) ListNav {
return ListNav{
BasePath: "/operator/billing/invoices",
FacetParam: "status",
Q: "acme",
Facet: "open",
Page: 2,
Extra: EnvExtra(all),
}
}
func TestEnvironmentNoticeWording(t *testing.T) {
cases := []struct {
name string
filter billingEnvFilter
wantText string
wantLabel string
}{
{
name: "hidden rows name themselves and offer the switch",
filter: billingEnvFilter{KeyMode: "live", Hidden: 12},
wantText: "12 invoices from test mode are not shown.",
wantLabel: "Show all",
},
{
name: "one hidden row reads as one",
filter: billingEnvFilter{KeyMode: "live", Hidden: 1},
wantText: "1 invoice from test mode is not shown.",
wantLabel: "Show all",
},
{
name: "under a test key the other environment is live",
filter: billingEnvFilter{KeyMode: "test", Hidden: 3},
wantText: "3 invoices from live mode are not shown.",
wantLabel: "Show all",
},
{
name: "the all state names itself and offers the way back",
filter: billingEnvFilter{KeyMode: "live", Hidden: 12, All: true},
wantText: "Showing all environments.",
wantLabel: "Show live only",
},
{
name: "the all state under a test key",
filter: billingEnvFilter{KeyMode: "test", Hidden: 12, All: true},
wantText: "Showing all environments.",
wantLabel: "Show test only",
},
{
name: "nothing hidden renders no line",
filter: billingEnvFilter{KeyMode: "live"},
},
{
name: "an unclassified key hides nothing and says nothing",
filter: billingEnvFilter{Hidden: 12},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
n := environmentNotice(billingNav(tc.filter.All), tc.filter, "invoice", "invoices")
if n.Text != tc.wantText {
t.Errorf("Text = %q, want %q", n.Text, tc.wantText)
}
if n.LinkLabel != tc.wantLabel {
t.Errorf("LinkLabel = %q, want %q", n.LinkLabel, tc.wantLabel)
}
if n.Show() != (tc.wantText != "") {
t.Errorf("Show() = %v for text %q", n.Show(), n.Text)
}
if !n.Show() {
return
}
if strings.Contains(n.Text, "—") {
t.Errorf("the line uses an em dash: %q", n.Text)
}
// The switch keeps the search and the facet and returns to
// page one, because the state change re-windows the whole set.
if !strings.Contains(n.LinkHref, "q=acme") || !strings.Contains(n.LinkHref, "status=open") {
t.Errorf("LinkHref = %q, want it to keep the search and the facet", n.LinkHref)
}
if strings.Contains(n.LinkHref, "page=") {
t.Errorf("LinkHref = %q, want it to return to page one", n.LinkHref)
}
wantEnv := !tc.filter.All
if strings.Contains(n.LinkHref, "env=all") != wantEnv {
t.Errorf("LinkHref = %q, want env=all present: %v", n.LinkHref, wantEnv)
}
})
}
}
// TestEnvAllTravelsTheScaffold pins task 6.4: the parameter rides
// ListNav.Extra, so every URL the scaffold builds and every hidden input
// the search form emits carries it.
func TestEnvAllTravelsTheScaffold(t *testing.T) {
nav := billingNav(true)
nav.PerPageOptions = perPageOptions
for name, got := range map[string]string{
"next page": nav.NextURL(),
"previous page": nav.PrevURL(),
"facet": nav.FacetURL("paid"),
"clear search": nav.ClearSearchURL(),
"page size": nav.PerPageURL(10),
} {
if !strings.Contains(got, "env=all") {
t.Errorf("%s URL = %q, want it to carry env=all", name, got)
}
}
if got := nav.ExtraInputs(); got.Get("env") != "all" {
t.Errorf("search form hidden inputs = %v, want env=all", got)
}
// The ordinary state adds nothing, so a canonical view keeps a
// canonical URL.
plain := billingNav(false)
if strings.Contains(plain.NextURL(), "env=") {
t.Errorf("the ordinary state must add no env parameter, got %q", plain.NextURL())
}
if EnvExtra(false) != nil {
t.Error("the ordinary state carries no Extra")
}
}
func TestParseEnvAll(t *testing.T) {
for target, want := range map[string]bool{
"/operator/billing/invoices": false,
"/operator/billing/invoices?env=all": true,
"/operator/billing/invoices?env=live": false,
"/operator/billing/invoices?env=": false,
"/operator/billing/invoices?q=a&env=all": true,
} {
r := httptest.NewRequest(http.MethodGet, target, nil)
if got := ParseEnvAll(r); got != want {
t.Errorf("ParseEnvAll(%q) = %v, want %v", target, got, want)
}
}
}
// TestRowEnvState pins task 6.6: only a row from the environment the key
// is not in earns a badge, and only while the view shows both.
func TestRowEnvState(t *testing.T) {
live := sql.NullBool{Bool: true, Valid: true}
test := sql.NullBool{Bool: false, Valid: true}
unverified := sql.NullBool{}
cases := []struct {
name string
livemode sql.NullBool
keyMode string
all bool
want string
}{
{"test row under a live key, all state", test, "live", true, "test"},
{"live row under a test key, all state", live, "test", true, "live"},
{"a row in the key's own environment", live, "live", true, ""},
{"an unverified row", unverified, "live", true, ""},
{"the ordinary state badges nothing", test, "live", false, ""},
{"an unclassified key badges nothing", test, "", true, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := rowEnvState(tc.livemode, tc.keyMode, tc.all); got != tc.want {
t.Errorf("rowEnvState = %q, want %q", got, tc.want)
}
})
}
}
@@ -0,0 +1,136 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
// The member catalog's purchasability gate under the Stripe environment
// stamp (stripe-environment-stamp D5, the seventh consumer): a price whose
// mapping records the environment the key is not in, or that the
// environment check found missing, is not purchasable, so the Extras
// bucket does not list the product and a plan tier's move control renders
// disabled instead of offering a checkout HandleCheckout would refuse.
//
// DB-backed via TEST_DATABASE_URL, and the rows are committed rather than
// written inside a rolled-back transaction: the handler reads price
// mappings through its own *sql.DB (resolvePurchasableRecurring and
// resolveTierPrice build the Stripe querier from h.Database), which cannot
// see another connection's open transaction, so the rollback shape
// member_extras_test.go uses cannot supply the mapping this gate turns on.
// Each fixture deletes its own rows.
import (
"context"
"database/sql"
"io"
"log/slog"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"github.com/google/uuid"
)
func TestMemberCatalogGateFollowsTheMappingEnvironment(t *testing.T) {
database := newRollbackTestDB(t)
ctx := context.Background()
sfx := uuid.New().String()[:8]
h := &MemberProductsHandler{
BillingQ: billing.New(database),
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Database: database,
StripeMode: "live",
}
// seed writes one public, published product with one active, recurring,
// default price and a price mapping in the given state, and returns the
// product id.
seed := func(name, syncStatus string, livemode sql.NullBool) string {
t.Helper()
var productID string
if err := database.QueryRowContext(ctx,
`INSERT INTO core.products (name, lifecycle_status, is_active, is_public)
VALUES ($1,'published',true,true) RETURNING product_id`,
name+" "+sfx,
).Scan(&productID); err != nil {
t.Fatalf("create product %s: %v", name, err)
}
var priceID string
if err := database.QueryRowContext(ctx,
`INSERT INTO core.prices (product_id, currency, unit_amount, recurring_interval, is_active, is_default)
VALUES ($1,'usd',1000,'month',true,true) RETURNING price_id`,
productID,
).Scan(&priceID); err != nil {
t.Fatalf("create price for %s: %v", name, err)
}
if _, err := database.ExecContext(ctx,
`INSERT INTO stripe.price_mappings (price_id, stripe_price_id, sync_status, livemode)
VALUES ($1,$2,$3,$4)`,
priceID, "price_"+priceID, syncStatus, livemode,
); err != nil {
t.Fatalf("create price mapping for %s: %v", name, err)
}
t.Cleanup(func() {
_, _ = database.ExecContext(context.Background(),
`DELETE FROM stripe.price_mappings WHERE price_id = $1`, priceID)
_, _ = database.ExecContext(context.Background(),
`DELETE FROM core.prices WHERE price_id = $1`, priceID)
_, _ = database.ExecContext(context.Background(),
`DELETE FROM core.products WHERE product_id = $1`, productID)
})
return productID
}
live := sql.NullBool{Bool: true, Valid: true}
test := sql.NullBool{Bool: false, Valid: true}
reachable := seed("EnvGate Reachable", "synced", live)
unverified := seed("EnvGate Unverified", "synced", sql.NullBool{})
otherEnv := seed("EnvGate Other", "synced", test)
stale := seed("EnvGate Stale", "stale", live)
data := h.buildAddonsData(ctx)
if data.Error != "" {
t.Fatalf("buildAddonsData: %s", data.Error)
}
listed := make(map[string]bool, len(data.Addons))
for _, a := range data.Addons {
listed[a.ProductID] = true
}
cases := []struct {
name string
productID string
wantPurchasable bool
}{
{"recorded in the key's own environment", reachable, true},
{"unverified, which blocks nothing", unverified, true},
{"recorded in test under a live key", otherEnv, false},
{"found missing by the environment check", stale, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if listed[tc.productID] != tc.wantPurchasable {
t.Errorf("listed in Extras = %v, want %v", listed[tc.productID], tc.wantPurchasable)
}
priceID, purchasable, priceText, noPrice := h.resolveTierPrice(ctx, tc.productID)
if purchasable != tc.wantPurchasable {
t.Errorf("resolveTierPrice purchasable = %v, want %v", purchasable, tc.wantPurchasable)
}
if noPrice {
t.Error("the fixture carries an active price, so noPrice must be false")
}
if priceID == "" {
t.Error("resolveTierPrice must still name the tracked price")
}
// A tier the key cannot reach shows no cost either: the
// disabled move control is the whole offer.
if !tc.wantPurchasable && priceText != "" {
t.Errorf("priceText = %q, want none for a price the key cannot reach", priceText)
}
if tc.wantPurchasable && priceText == "" {
t.Error("a purchasable tier must state its cost")
}
})
}
}
+45
View File
@@ -4,6 +4,7 @@
package server
import (
"context"
"database/sql"
"html/template"
"io/fs"
@@ -28,6 +29,11 @@ type MemberInvoicesHandler struct {
AuthConfig *auth.Config
Logger *slog.Logger
Templates *SafeTemplates
// StripeMode is the environment derived from the API key at boot, the
// same value the operator views read. The member's own invoices are
// filtered to it with no switch: a member has no business with the
// other environment's figures (stripe-environment-stamp D7).
StripeMode string
}
// MemberInvoicesConfig holds configuration for the member invoices handler.
@@ -36,6 +42,7 @@ type MemberInvoicesConfig struct {
StripeQ stripedb.Querier
AuthConfig *auth.Config
Logger *slog.Logger
StripeMode string
}
// NewMemberInvoicesHandler creates a new MemberInvoicesHandler.
@@ -62,6 +69,7 @@ func NewMemberInvoicesHandler(cfg MemberInvoicesConfig) (*MemberInvoicesHandler,
AuthConfig: cfg.AuthConfig,
Logger: cfg.Logger,
Templates: NewSafeTemplates(tmpl, cfg.Logger),
StripeMode: cfg.StripeMode,
}, nil
}
@@ -207,6 +215,13 @@ func (h *MemberInvoicesHandler) GetInvoices(w http.ResponseWriter, r *http.Reque
return invoices[i].CreatedAt.After(invoices[j].CreatedAt)
})
// Drop the invoices whose mapping records the environment the API key
// is not in. The view is unpaged, so the filter runs here rather than
// through an exclusion the query takes; an unverified mapping and a
// deployment whose key mode is unknown hide nothing, the same rule
// every consumer of the stamp follows (stripe-environment-stamp D7).
invoices = h.invoicesInKeyEnvironment(r.Context(), invoices)
for _, inv := range invoices {
data.Invoices = append(data.Invoices, InvoiceListItemViewModel{
InvoiceID: inv.InvoiceID,
@@ -223,6 +238,36 @@ func (h *MemberInvoicesHandler) GetInvoices(w http.ResponseWriter, r *http.Reque
h.Templates.Render(w, "member_invoices.html", data)
}
// invoicesInKeyEnvironment drops the invoices recorded in the environment
// the API key is not in. It reads the excluded ids through the same store
// query the operator views hand to their paged queries, one read for the
// whole list rather than one per row, and returns the list unchanged when
// there is no Stripe querier or no classified key mode.
func (h *MemberInvoicesHandler) invoicesInKeyEnvironment(ctx context.Context, invoices []billing.Invoice) []billing.Invoice {
if h.StripeQ == nil || h.StripeMode == "" || len(invoices) == 0 {
return invoices
}
outside, err := h.StripeQ.ListInvoiceIDsOutsideMode(ctx, h.StripeMode == stripedb.ModeLive)
if err != nil {
h.Logger.Warn("failed to resolve invoices outside the key's stripe environment", slog.Any("error", err))
return invoices
}
if len(outside) == 0 {
return invoices
}
hidden := make(map[string]struct{}, len(outside))
for _, id := range outside {
hidden[id] = struct{}{}
}
kept := invoices[:0]
for _, inv := range invoices {
if _, skip := hidden[inv.InvoiceID]; !skip {
kept = append(kept, inv)
}
}
return kept
}
// GetInvoiceDetail handles GET /partials/member/invoices/{invoiceID}. It renders
// a single invoice with its line items and payments, but only when the invoice
// belongs to one of the member organization's billing accounts. Invoices that
+19 -3
View File
@@ -33,6 +33,10 @@ type MemberProductsHandler struct {
AuthConfig *auth.Config
Logger *slog.Logger
Templates *SafeTemplates
// StripeMode is the environment derived from the API key at boot
// (server.Config.StripeMode): the purchasability gate refuses a price
// whose mapping the key cannot reach (stripe-environment-stamp D5).
StripeMode string
// Database is used to resolve Stripe price mappings when determining
// whether a plan is purchasable. May be nil (e.g. in tests or when Stripe
// is not configured), in which case plans are treated as not purchasable.
@@ -46,6 +50,9 @@ type MemberProductsConfig struct {
AuthConfig *auth.Config
Logger *slog.Logger
Database *sql.DB
// StripeMode is the environment derived from the API key at boot; see
// MemberProductsHandler.StripeMode.
StripeMode string
}
// NewMemberProductsHandler creates a new MemberProductsHandler.
@@ -73,6 +80,7 @@ func NewMemberProductsHandler(cfg MemberProductsConfig) (*MemberProductsHandler,
Logger: cfg.Logger,
Templates: NewSafeTemplates(tmpl, cfg.Logger),
Database: cfg.Database,
StripeMode: cfg.StripeMode,
}, nil
}
@@ -885,7 +893,8 @@ func (h *MemberProductsHandler) renderPlansAfterMove(w http.ResponseWriter, ctx
data.Error = "There's no active subscription to change on this plan."
case errors.Is(moveErr, fulfillment.ErrSameTier):
data.Error = "You're already on that plan."
case errors.Is(moveErr, fulfillment.ErrPriceInactive), errors.Is(moveErr, errPlanNotOpenToMembers):
case errors.Is(moveErr, fulfillment.ErrPriceInactive), errors.Is(moveErr, errPlanNotOpenToMembers),
errors.Is(moveErr, fulfillment.ErrPriceUnreachable):
data.Error = "That plan is no longer available. Refresh the page and choose a current plan."
default:
data.Error = "We couldn't complete that change. Try again in a moment."
@@ -1129,7 +1138,11 @@ func (h *MemberProductsHandler) resolvePurchasable(ctx context.Context, productI
stripeQ = internalstripe.New(h.Database)
}
r := computePriceReadiness(ctx, h.BillingQ, stripeQ, stripeQ != nil, productID)
return r.PriceID, r.HasActivePrice && r.StripeMapped
// A mapping the current key cannot reach (recorded in the other
// environment, or marked stale by the environment check) is not
// purchasable either: offering it would send the member to a checkout
// the gate in HandleCheckout refuses (stripe-environment-stamp D5).
return r.PriceID, r.HasActivePrice && r.StripeMapped && internalstripe.MappingReachable(r.Livemode, r.SyncStatus, h.StripeMode)
}
// resolveTierPrice extends resolvePurchasable's readiness check with the
@@ -1145,7 +1158,10 @@ func (h *MemberProductsHandler) resolveTierPrice(ctx context.Context, productID
}
r := computePriceReadiness(ctx, h.BillingQ, stripeQ, stripeQ != nil, productID)
priceID = r.PriceID
purchasable = r.HasActivePrice && r.StripeMapped
// The same reachability question resolvePurchasable asks: a tier whose
// mapping the key cannot reach renders its move control disabled
// rather than offering a checkout the gate refuses.
purchasable = r.HasActivePrice && r.StripeMapped && internalstripe.MappingReachable(r.Livemode, r.SyncStatus, h.StripeMode)
if !r.HasActivePrice {
noPrice = true
return priceID, purchasable, priceText, noPrice
+288 -6
View File
@@ -22,6 +22,182 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
// EnvironmentNotice is the one muted line above a billing view that names
// the rows the environment filter is holding back and carries the switch
// between the two states (stripe-environment-stamp D7). It is a
// data-preservation fact, not a hint: the rows exist, this view is not
// showing them, and the line says which and how many.
//
// Text is empty when the filter hides nothing, and the template renders
// neither the line nor the switch then.
type EnvironmentNotice struct {
Text string
LinkLabel string
LinkHref string
// Target and SyncSelect are the hosting list's htmx selectors, copied
// from its ListNav. When Target is set the switch issues the scoped
// request every other control of the scaffold issues, so an embedded
// view swaps its own panel instead of navigating the whole page; when
// it is empty the switch is a plain link.
Target string
SyncSelect string
}
// Show reports whether the line renders.
func (n EnvironmentNotice) Show() bool { return n.Text != "" }
// billingEnvFilter is one billing view's resolved environment state: which
// core ids the paged query must leave out, how many rows that is, and the
// mode the key is in. Resolved once per request, before the page is
// fetched, because filtering after FetchPage would leave the pager
// counting rows it does not render (task 6.1).
type billingEnvFilter struct {
// All is the env=all state: nothing is excluded and the rows from the
// other environment carry a badge.
All bool
// OutsideIDs are the core ids whose stripe mapping records the
// environment the key is not in. Resolved in both states: the default
// state hands them to the paged query, and the all state still needs
// them to count what the default state would have hidden.
OutsideIDs []string
// Hidden is how many rows the filter holds back from this view under
// the search and the facet the operator has in force, which is what
// the absence line names. Resolved by resolveHidden, not by counting
// OutsideIDs: a search that matches none of the out-of-environment
// rows hides nothing, and the line must not claim otherwise.
Hidden int64
// KeyMode is the environment the API key is in, "" when the deployment
// has no key the console could classify. An empty mode filters nothing:
// there is no environment to compare a stamp against.
KeyMode string
}
// ExcludeIDs is what the paged query must leave out: the out-of-environment
// ids in the default state, nothing in the all state.
func (f billingEnvFilter) ExcludeIDs() []string {
if f.All {
return nil
}
return f.OutsideIDs
}
// The four billing views the environment filter applies to, named so
// resolveEnvFilter can pick each one's *OutsideMode pair in one place
// rather than four call sites passing method values (which would panic on
// the nil Stripe querier a deployment without Stripe carries).
type billingEnvView string
const (
envViewAccounts billingEnvView = "billing accounts"
envViewSubscriptions billingEnvView = "subscriptions"
envViewInvoices billingEnvView = "invoices"
envViewPayments billingEnvView = "payments"
)
// resolveEnvFilter reads one view's out-of-environment ids from the Stripe
// store. Both states read them: the default state excludes them from the
// page, and the all state needs them to say how many rows it is showing
// that the default state would not.
func (h *OperatorPartialsHandler) resolveEnvFilter(ctx context.Context, view billingEnvView, all bool) billingEnvFilter {
f := billingEnvFilter{All: all, KeyMode: h.StripeMode}
if h.StripeQ == nil || h.StripeMode == "" {
return f
}
live := h.StripeMode == stripedb.ModeLive
var (
ids []string
err error
)
switch view {
case envViewAccounts:
ids, err = h.StripeQ.ListBillingAccountIDsOutsideMode(ctx, live)
case envViewSubscriptions:
ids, err = h.StripeQ.ListSubscriptionIDsOutsideMode(ctx, live)
case envViewInvoices:
ids, err = h.StripeQ.ListInvoiceIDsOutsideMode(ctx, live)
case envViewPayments:
ids, err = h.StripeQ.ListPaymentIDsOutsideMode(ctx, live)
}
if err != nil {
h.Logger.Warn("failed to resolve rows outside the key's stripe environment",
slog.String("view", string(view)), slog.Any("error", err))
return f
}
f.OutsideIDs = ids
return f
}
// resolveHidden counts how many rows the environment filter holds back
// from this view, under every predicate the page query applies: the
// search the operator typed, the status facet, and the ids a search
// pre-resolved elsewhere. count runs the view's count query with the
// exclusion it is handed, so the two runs differ in nothing but the
// exclusion and their difference is exactly what the line must name.
// Counting OutsideIDs instead would report the whole out-of-environment
// set, which a search matching none of it would contradict.
func (f *billingEnvFilter) resolveHidden(logger *slog.Logger, view billingEnvView, count func(exclude []string) (int64, error)) {
if f.KeyMode == "" || len(f.OutsideIDs) == 0 {
return
}
shown, err := count(f.OutsideIDs)
if err == nil {
var all int64
all, err = count(nil)
if err == nil && all > shown {
f.Hidden = all - shown
}
}
if err != nil {
logger.Warn("failed to count rows outside the key's stripe environment",
slog.String("view", string(view)), slog.Any("error", err))
}
}
// environmentNotice builds the absence line for one view. singular and
// plural are that view's noun; the environment word is the other one's,
// because the line is about what is not here.
func environmentNotice(nav ListNav, f billingEnvFilter, singular, plural string) EnvironmentNotice {
if f.KeyMode == "" {
return EnvironmentNotice{}
}
// The all state always carries the line, hidden rows or none: it is the
// only way back to the key's own environment, and an operator who
// switched to all and then searched would otherwise be stranded there.
if f.All {
return EnvironmentNotice{
Text: "Showing all environments.",
LinkLabel: "Show " + f.KeyMode + " only",
LinkHref: nav.EnvURL(false),
Target: nav.Target,
SyncSelect: nav.SyncSelect,
}
}
if f.Hidden == 0 {
return EnvironmentNotice{}
}
return EnvironmentNotice{
Text: formatCount(f.Hidden) + " " + pluralize(f.Hidden, singular, plural) +
" from " + stripedb.OtherMode(f.KeyMode) + " mode " +
pluralize(f.Hidden, "is", "are") + " not shown.",
LinkLabel: "Show all",
LinkHref: nav.EnvURL(true),
Target: nav.Target,
SyncSelect: nav.SyncSelect,
}
}
// rowEnvState is the badge state for one row in the all state: the
// environment the row's mapping records, when that is the one the key is
// not in. Empty everywhere else, and the template renders no badge then,
// so only the rows the ordinary state would have hidden are marked.
func rowEnvState(livemode sql.NullBool, keyMode string, all bool) string {
if !all || keyMode == "" || stripedb.MappingAgrees(livemode, keyMode) {
return ""
}
return stripedb.OtherMode(keyMode)
}
// BillingAccountViewModel represents a billing account for operator template rendering
type BillingAccountViewModel struct {
BillingAccountID string
@@ -32,12 +208,19 @@ type BillingAccountViewModel struct {
StripeCustomerID string
StripeSyncStatus string
CreatedAt string
// EnvState is the Stripe environment this row's mapping records when
// the view is showing every environment and this row is from the one
// the key is not in; "" otherwise (stripe-environment-stamp D7).
EnvState string
}
// BillingAccountsData holds data for the billing accounts list partial
type BillingAccountsData struct {
Accounts []BillingAccountViewModel
Error string
// EnvNotice is the absence line and its switch, empty when the
// environment filter hides nothing (stripe-environment-stamp D7).
EnvNotice EnvironmentNotice
// StripeConfigured gates the empty-view copy (see
// operator_billing_accounts.html): an empty list because Stripe was
// never configured (blocked on the operator) must read differently from
@@ -134,6 +317,7 @@ func (h *OperatorPartialsHandler) billingAccountsListNav(r *http.Request) ListNa
SearchPlaceholder: "Search by organization or account name",
Q: params.Q,
Page: params.Page,
Extra: EnvExtra(ParseEnvAll(r)),
}
}
@@ -147,10 +331,19 @@ func (h *OperatorPartialsHandler) loadBillingAccountsData(r *http.Request) Billi
nav := h.billingAccountsListNav(r)
params := ListParams{Q: nav.Q, Page: nav.Page}
orgIDs := h.matchingOrgIDs(ctx, params.Q)
env := h.resolveEnvFilter(ctx, envViewAccounts, ParseEnvAll(r))
env.resolveHidden(h.Logger, envViewAccounts, func(exclude []string) (int64, error) {
return h.BillingQ.CountBillingAccountsPage(ctx, billing.CountBillingAccountsPageParams{
Q: sql.NullString{String: params.Q, Valid: params.Q != ""},
OrgIds: orgIDs,
ExcludeIds: exclude,
})
})
accounts, total, err := FetchPage(&params, func(limit, offset int32) ([]billing.ListBillingAccountsPageRow, int64, error) {
rows, lErr := h.BillingQ.ListBillingAccountsPage(ctx, billing.ListBillingAccountsPageParams{
Q: sql.NullString{String: params.Q, Valid: params.Q != ""},
OrgIds: orgIDs,
ExcludeIds: env.ExcludeIDs(),
PageLimit: limit,
PageOffset: offset,
})
@@ -174,11 +367,13 @@ func (h *OperatorPartialsHandler) loadBillingAccountsData(r *http.Request) Billi
stripeCustomerID := ""
syncStatus := "not_mapped"
envState := ""
if mapping, err := h.StripeQ.GetCustomerMappingByBillingAccountID(ctx, acc.BillingAccountID); err == nil {
if mapping.StripeCustomerID.Valid {
stripeCustomerID = mapping.StripeCustomerID.String
}
syncStatus = mapping.SyncStatus
envState = rowEnvState(mapping.Livemode, h.StripeMode, env.All)
}
vms[i] = BillingAccountViewModel{
@@ -190,12 +385,14 @@ func (h *OperatorPartialsHandler) loadBillingAccountsData(r *http.Request) Billi
StripeCustomerID: stripeCustomerID,
StripeSyncStatus: syncStatus,
CreatedAt: acc.CreatedAt.Format("Jan 2, 2006"),
EnvState: envState,
}
}
return BillingAccountsData{
Accounts: vms,
StripeConfigured: stripeConfigured,
Nav: nav,
EnvNotice: environmentNotice(nav, env, "billing account", "billing accounts"),
}
}
@@ -214,12 +411,17 @@ type SubscriptionViewModel struct {
StripeSubscriptionID string
StripeSyncStatus string
CreatedAt string
// EnvState marks a row from the environment the key is not in, in the
// all state only; see BillingAccountViewModel.
EnvState string
}
// SubscriptionsData holds data for the subscriptions list partial
type SubscriptionsData struct {
Subscriptions []SubscriptionViewModel
Error string
// EnvNotice is the absence line and its switch; see BillingAccountsData.
EnvNotice EnvironmentNotice
// StripeConfigured gates the empty-view copy; see BillingAccountsData.
StripeConfigured bool
// Nav drives the shared list-controls partial (operator-list-scale).
@@ -254,6 +456,7 @@ func (h *OperatorPartialsHandler) subscriptionsListNav(r *http.Request) ListNav
Q: params.Q,
Facet: facet,
Page: params.Page,
Extra: EnvExtra(ParseEnvAll(r)),
}
}
@@ -266,11 +469,21 @@ func (h *OperatorPartialsHandler) loadSubscriptionsData(r *http.Request) Subscri
nav := h.subscriptionsListNav(r)
params := ListParams{Q: nav.Q, Facet: nav.Facet, Page: nav.Page}
orgIDs := h.matchingOrgIDs(ctx, params.Q)
env := h.resolveEnvFilter(ctx, envViewSubscriptions, ParseEnvAll(r))
env.resolveHidden(h.Logger, envViewSubscriptions, func(exclude []string) (int64, error) {
return h.BillingQ.CountSubscriptionsPage(ctx, billing.CountSubscriptionsPageParams{
Q: sql.NullString{String: params.Q, Valid: params.Q != ""},
OrgIds: orgIDs,
Status: sql.NullString{String: params.Facet, Valid: params.Facet != ""},
ExcludeIds: exclude,
})
})
subs, total, err := FetchPage(&params, func(limit, offset int32) ([]billing.ListSubscriptionsPageRow, int64, error) {
rows, lErr := h.BillingQ.ListSubscriptionsPage(ctx, billing.ListSubscriptionsPageParams{
Q: sql.NullString{String: params.Q, Valid: params.Q != ""},
OrgIds: orgIDs,
Status: sql.NullString{String: params.Facet, Valid: params.Facet != ""},
ExcludeIds: env.ExcludeIDs(),
PageLimit: limit,
PageOffset: offset,
})
@@ -289,11 +502,13 @@ func (h *OperatorPartialsHandler) loadSubscriptionsData(r *http.Request) Subscri
for i, sub := range subs {
stripeSubID := ""
syncStatus := "not_mapped"
envState := ""
if mapping, err := h.StripeQ.GetSubscriptionMappingBySubscriptionID(ctx, sub.SubscriptionID); err == nil {
if mapping.StripeSubscriptionID.Valid {
stripeSubID = mapping.StripeSubscriptionID.String
}
syncStatus = mapping.SyncStatus
envState = rowEnvState(mapping.Livemode, h.StripeMode, env.All)
}
orgName := ""
@@ -324,9 +539,15 @@ func (h *OperatorPartialsHandler) loadSubscriptionsData(r *http.Request) Subscri
StripeSubscriptionID: stripeSubID,
StripeSyncStatus: syncStatus,
CreatedAt: sub.CreatedAt.Format("Jan 2, 2006"),
EnvState: envState,
}
}
return SubscriptionsData{Subscriptions: vms, StripeConfigured: stripeConfigured, Nav: nav}
return SubscriptionsData{
Subscriptions: vms,
StripeConfigured: stripeConfigured,
Nav: nav,
EnvNotice: environmentNotice(nav, env, "subscription", "subscriptions"),
}
}
// InvoiceViewModel represents an invoice for operator template rendering
@@ -356,12 +577,17 @@ type InvoiceViewModel struct {
StripeInvoiceID string
StripeSyncStatus string
CreatedAt string
// EnvState marks a row from the environment the key is not in, in the
// all state only; see BillingAccountViewModel.
EnvState string
}
// InvoicesData holds data for the invoices list partial
type InvoicesData struct {
Invoices []InvoiceViewModel
Error string
// EnvNotice is the absence line and its switch; see BillingAccountsData.
EnvNotice EnvironmentNotice
// StripeConfigured gates the empty-view copy; see BillingAccountsData.
StripeConfigured bool
// Nav drives the shared list-controls partial (operator-list-scale).
@@ -410,6 +636,7 @@ func (h *OperatorPartialsHandler) invoicesListNav(r *http.Request) ListNav {
Q: params.Q,
Facet: facet,
Page: params.Page,
Extra: EnvExtra(ParseEnvAll(r)),
}
}
@@ -452,12 +679,23 @@ func (h *OperatorPartialsHandler) loadInvoicesData(r *http.Request) InvoicesData
params := ListParams{Q: nav.Q, Facet: nav.Facet, Page: nav.Page}
orgIDs := h.matchingOrgIDs(ctx, params.Q)
invoiceIDs := h.matchingInvoiceIDsByStripeNumber(ctx, params.Q)
env := h.resolveEnvFilter(ctx, envViewInvoices, ParseEnvAll(r))
env.resolveHidden(h.Logger, envViewInvoices, func(exclude []string) (int64, error) {
return h.BillingQ.CountInvoicesPage(ctx, billing.CountInvoicesPageParams{
Q: sql.NullString{String: params.Q, Valid: params.Q != ""},
OrgIds: orgIDs,
InvoiceIds: invoiceIDs,
Status: sql.NullString{String: params.Facet, Valid: params.Facet != ""},
ExcludeIds: exclude,
})
})
invoices, total, err := FetchPage(&params, func(limit, offset int32) ([]billing.ListInvoicesPageRow, int64, error) {
rows, lErr := h.BillingQ.ListInvoicesPage(ctx, billing.ListInvoicesPageParams{
Q: sql.NullString{String: params.Q, Valid: params.Q != ""},
OrgIds: orgIDs,
InvoiceIds: invoiceIDs,
Status: sql.NullString{String: params.Facet, Valid: params.Facet != ""},
ExcludeIds: env.ExcludeIDs(),
PageLimit: limit,
PageOffset: offset,
})
@@ -476,11 +714,13 @@ func (h *OperatorPartialsHandler) loadInvoicesData(r *http.Request) InvoicesData
for i, inv := range invoices {
stripeInvoiceID := ""
syncStatus := "not_mapped"
envState := ""
if mapping, err := h.StripeQ.GetInvoiceMappingByInvoiceID(ctx, inv.InvoiceID); err == nil {
if mapping.StripeInvoiceID.Valid {
stripeInvoiceID = mapping.StripeInvoiceID.String
}
syncStatus = mapping.SyncStatus
envState = rowEnvState(mapping.Livemode, h.StripeMode, env.All)
}
orgName := ""
@@ -515,9 +755,15 @@ func (h *OperatorPartialsHandler) loadInvoicesData(r *http.Request) InvoicesData
StripeInvoiceID: stripeInvoiceID,
StripeSyncStatus: syncStatus,
CreatedAt: inv.CreatedAt.Format("Jan 2, 2006"),
EnvState: envState,
}
}
return InvoicesData{Invoices: vms, StripeConfigured: stripeConfigured, Nav: nav}
return InvoicesData{
Invoices: vms,
StripeConfigured: stripeConfigured,
Nav: nav,
EnvNotice: environmentNotice(nav, env, "invoice", "invoices"),
}
}
// OperatorInvoiceLineItemViewModel is one line item on the operator invoice
@@ -685,12 +931,17 @@ type PaymentViewModel struct {
StripeSyncStatus string
FailedAt string
CreatedAt string
// EnvState marks a row from the environment the key is not in, in the
// all state only; see BillingAccountViewModel.
EnvState string
}
// PaymentsData holds data for the payments list partial
type PaymentsData struct {
Payments []PaymentViewModel
Error string
// EnvNotice is the absence line and its switch; see BillingAccountsData.
EnvNotice EnvironmentNotice
// StripeConfigured gates the empty-view copy; see BillingAccountsData.
StripeConfigured bool
// Nav drives the shared list-controls partial (operator-list-scale).
@@ -708,6 +959,7 @@ func (h *OperatorPartialsHandler) paymentsListNav(r *http.Request) ListNav {
SearchPlaceholder: "Search by organization or billing account",
Q: params.Q,
Page: params.Page,
Extra: EnvExtra(ParseEnvAll(r)),
}
}
@@ -723,10 +975,19 @@ func (h *OperatorPartialsHandler) loadPaymentsData(r *http.Request) PaymentsData
nav := h.paymentsListNav(r)
params := ListParams{Q: nav.Q, Page: nav.Page}
orgIDs := h.matchingOrgIDs(ctx, params.Q)
env := h.resolveEnvFilter(ctx, envViewPayments, ParseEnvAll(r))
env.resolveHidden(h.Logger, envViewPayments, func(exclude []string) (int64, error) {
return h.BillingQ.CountPaymentsPage(ctx, billing.CountPaymentsPageParams{
Q: sql.NullString{String: params.Q, Valid: params.Q != ""},
OrgIds: orgIDs,
ExcludeIds: exclude,
})
})
payments, total, err := FetchPage(&params, func(limit, offset int32) ([]billing.ListPaymentsPageRow, int64, error) {
rows, lErr := h.BillingQ.ListPaymentsPage(ctx, billing.ListPaymentsPageParams{
Q: sql.NullString{String: params.Q, Valid: params.Q != ""},
OrgIds: orgIDs,
ExcludeIds: env.ExcludeIDs(),
PageLimit: limit,
PageOffset: offset,
})
@@ -745,12 +1006,14 @@ func (h *OperatorPartialsHandler) loadPaymentsData(r *http.Request) PaymentsData
for i, pay := range payments {
stripePaymentIntentID := ""
syncStatus := "not_mapped"
envState := ""
if h.StripeQ != nil {
if mapping, err := h.StripeQ.GetPaymentMappingByPaymentID(ctx, pay.PaymentID); err == nil {
if mapping.StripePaymentIntentID.Valid {
stripePaymentIntentID = mapping.StripePaymentIntentID.String
}
syncStatus = mapping.SyncStatus
envState = rowEnvState(mapping.Livemode, h.StripeMode, env.All)
}
}
@@ -793,9 +1056,15 @@ func (h *OperatorPartialsHandler) loadPaymentsData(r *http.Request) PaymentsData
StripeSyncStatus: syncStatus,
FailedAt: failedAt,
CreatedAt: pay.CreatedAt.Format("Jan 2, 2006"),
EnvState: envState,
}
}
return PaymentsData{Payments: vms, StripeConfigured: stripeConfigured, Nav: nav}
return PaymentsData{
Payments: vms,
StripeConfigured: stripeConfigured,
Nav: nav,
EnvNotice: environmentNotice(nav, env, "payment", "payments"),
}
}
// PriceViewModel represents a price for operator template rendering
@@ -1095,8 +1364,16 @@ func (h *OperatorPartialsHandler) SyncProductToStripe(w http.ResponseWriter, r *
// terminally-failed (dead-lettered) sync leaves the mapping stuck at 'pending',
// so treat that as a retry — re-drive the dead-lettered entries — rather than a
// no-op on the stuck pending mapping.
//
// "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 and the environment check has not marked the row stale
// (stripe-environment-stamp D5). Otherwise the press falls through to
// the never-synced path below, which creates the object again in the
// environment the key is in and lets the executor's mapping replace the
// unreachable id.
m, mErr := h.StripeQ.GetPriceMappingByPriceID(ctx, price.PriceID)
if mErr == nil && m.StripePriceID.Valid {
if mErr == nil && m.StripePriceID.Valid && stripedb.MappingReachable(m.Livemode, m.SyncStatus, h.StripeMode) {
h.renderProductEditPage(w, r, productID, "This price is already synced to Stripe.", "")
return
}
@@ -1150,7 +1427,7 @@ func (h *OperatorPartialsHandler) SyncProductToStripe(w http.ResponseWriter, r *
// between our pre-lock guards and acquiring the lock may already have
// driven this price to synced or pending.
if pm, err := qtx.GetPriceMappingByPriceID(ctx, price.PriceID); err == nil {
if pm.StripePriceID.Valid {
if pm.StripePriceID.Valid && stripedb.MappingReachable(pm.Livemode, pm.SyncStatus, h.StripeMode) {
h.renderProductEditPage(w, r, productID, "This price is already synced to Stripe.", "")
return
}
@@ -1166,10 +1443,15 @@ func (h *OperatorPartialsHandler) SyncProductToStripe(w http.ResponseWriter, r *
// product). A pending product mapping with no ID yet means the product
// create is still in flight; adding a price entry now could land before
// its parent product, so treat it as in-progress.
//
// A product id the current key cannot reach is not a product that
// already exists here: the price create would attach the new price to a
// product in the other environment and fail, so the product is created
// again too (stripe-environment-stamp D5).
productSynced := false
if pm, err := qtx.GetProductMappingByProductID(ctx, productID); err == nil {
if pm.StripeProductID.Valid {
productSynced = true
productSynced = stripedb.MappingReachable(pm.Livemode, pm.SyncStatus, h.StripeMode)
} else if pm.SyncStatus == "pending" {
h.renderProductEditPage(w, r, productID, "A Stripe sync is already in progress for this product.", "")
return
@@ -0,0 +1,535 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server_test
// The operator billing views' Stripe environment filter
// (stripe-environment-stamp D7, task 6.8): each of the four views shows
// only the rows whose mapping records the environment the API key is in,
// or none; the absence line names what it is holding back and carries the
// switch; env=all shows everything with a badge on the other
// environment's rows and survives a search, a facet change and a page;
// and the member's own invoices are filtered with no switch at all.
//
// DB-backed via TEST_DATABASE_URL. The shared database accumulates rows
// across runs, so every assertion is scoped to the fixture's own marker.
import (
"context"
"database/sql"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/alexedwards/scs/v2"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
)
// billingEnvEnv is one org whose billing rows are split across the two
// Stripe environments, plus an operator session and the handler under
// test wired to a chosen key mode.
type billingEnvEnv struct {
t *testing.T
database *sql.DB
mux *http.ServeMux
operator context.Context
member context.Context
marker string
orgID string
accountID string
}
func newBillingEnvEnv(t *testing.T, keyMode string) *billingEnvEnv {
t.Helper()
database := testDB(t)
ctx := context.Background()
sm := scs.New()
authCfg := &auth.Config{SessionManager: sm}
handler, err := server.NewOperatorPartialsHandler(server.OperatorPartialsConfig{
Database: database,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
AuthConfig: authCfg,
BillingQ: billing.New(database),
EntitlementsQ: entitlements.New(database),
OrgQ: organization.New(database),
StripeQ: stripedb.New(database),
StripeConfigured: true,
StripeMode: keyMode,
})
if err != nil {
t.Fatalf("NewOperatorPartialsHandler: %v", err)
}
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
opCtx, err := sm.Load(ctx, "")
if err != nil {
t.Fatalf("session load: %v", err)
}
sm.Put(opCtx, "authenticated", true)
sm.Put(opCtx, "roles", []string{server.OperatorRole})
marker := fmt.Sprintf("be%d", time.Now().UnixNano())
scan := func(query string, args ...any) string {
var id string
if err := database.QueryRowContext(ctx, query, args...).Scan(&id); err != nil {
t.Fatalf("fixture %q: %v", query, err)
}
return id
}
userID := scan(`INSERT INTO core.users (oidc_subject) VALUES ($1) RETURNING user_id`, "sub-"+marker)
personID := scan(`INSERT INTO core.persons (user_id, display_name, primary_email) VALUES ($1,$2,$3) RETURNING person_id`,
userID, "Billing Env "+marker, marker+"@example.test")
orgType := "be" + marker[len(marker)-12:]
if _, err := database.ExecContext(ctx,
`INSERT INTO core.org_types (org_type, display_name) VALUES ($1, $1)`, orgType); err != nil {
t.Fatalf("fixture org type: %v", err)
}
orgID := scan(`INSERT INTO core.organizations (name, org_type, owner_person_id) VALUES ($1,$2,$3) RETURNING org_id`,
"BillingEnvOrg "+marker, orgType, personID)
accountID := scan(`INSERT INTO core.accounts (org_id, name, status) VALUES ($1,$2,'active') RETURNING billing_account_id`,
orgID, "BillingEnvAccount "+marker)
memberCtx, err := sm.Load(ctx, "")
if err != nil {
t.Fatalf("session load: %v", err)
}
sm.Put(memberCtx, "authenticated", true)
sm.Put(memberCtx, "org_id", orgID)
return &billingEnvEnv{
t: t, database: database, mux: mux,
operator: opCtx, member: memberCtx,
marker: marker, orgID: orgID, accountID: accountID,
}
}
func (e *billingEnvEnv) exec(query string, args ...any) {
e.t.Helper()
if _, err := e.database.ExecContext(context.Background(), query, args...); err != nil {
e.t.Fatalf("fixture %q: %v", query, err)
}
}
func (e *billingEnvEnv) scan(query string, args ...any) string {
e.t.Helper()
var id string
if err := e.database.QueryRowContext(context.Background(), query, args...).Scan(&id); err != nil {
e.t.Fatalf("fixture %q: %v", query, err)
}
return id
}
// seedInvoice writes one invoice and its mapping with the given stamp.
// livemode nil leaves the column NULL, the unverified state.
func (e *billingEnvEnv) seedInvoice(number string, livemode *bool) string {
e.t.Helper()
id := e.scan(
`INSERT INTO core.invoices (billing_account_id, status, amount_due, amount_paid, currency, invoice_number)
VALUES ($1,'open',1000,0,'usd',$2) RETURNING invoice_id`,
e.accountID, number)
e.exec(`INSERT INTO stripe.invoice_mappings (invoice_id, stripe_invoice_id, sync_status, livemode)
VALUES ($1,$2,'synced',$3)`, id, "in_"+id, nullBool(livemode))
return id
}
func (e *billingEnvEnv) seedPayment(invoiceID string, livemode *bool) {
e.t.Helper()
id := e.scan(
`INSERT INTO core.payments (billing_account_id, invoice_id, amount, currency, status)
VALUES ($1,$2,1000,'usd','succeeded') RETURNING payment_id`,
e.accountID, invoiceID)
e.exec(`INSERT INTO stripe.payment_mappings (payment_id, stripe_payment_intent_id, sync_status, livemode)
VALUES ($1,$2,'synced',$3)`, id, "pi_"+id, nullBool(livemode))
}
func (e *billingEnvEnv) seedSubscription(livemode *bool) {
e.t.Helper()
id := e.scan(
`INSERT INTO core.subscriptions (billing_account_id, status) VALUES ($1,'active') RETURNING subscription_id`,
e.accountID)
e.exec(`INSERT INTO stripe.subscription_mappings (subscription_id, stripe_subscription_id, sync_status, livemode)
VALUES ($1,$2,'synced',$3)`, id, "sub_"+id, nullBool(livemode))
}
// seedAccountMapping stamps the fixture's own billing account, the row the
// accounts view filters on.
func (e *billingEnvEnv) seedAccountMapping(livemode *bool) {
e.t.Helper()
e.exec(`INSERT INTO stripe.customer_mappings (billing_account_id, stripe_customer_id, sync_status, livemode)
VALUES ($1,$2,'synced',$3)`, e.accountID, "cus_"+e.accountID, nullBool(livemode))
}
func (e *billingEnvEnv) get(ctx context.Context, target string) string {
e.t.Helper()
req := httptest.NewRequestWithContext(ctx, http.MethodGet, target, nil)
rec := httptest.NewRecorder()
e.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
e.t.Fatalf("GET %s: status %d", target, rec.Code)
}
return rec.Body.String()
}
// TestBillingViewsFilterToTheKeyEnvironment pins the operator-billing-views
// scenarios "A billing view shows the key's environment", "An unverified
// row shows under either key", "The absence line names what is not shown
// and switches" and "The all state names itself and badges the other
// environment".
func TestBillingViewsFilterToTheKeyEnvironment(t *testing.T) {
live, test := true, false
e := newBillingEnvEnv(t, "live")
liveInvoice := e.seedInvoice("LIVE-"+e.marker, &live)
testInvoice := e.seedInvoice("TEST-"+e.marker, &test)
e.seedInvoice("NULL-"+e.marker, nil)
e.seedPayment(liveInvoice, &live)
e.seedPayment(testInvoice, &test)
e.seedSubscription(&live)
e.seedSubscription(&test)
e.seedAccountMapping(&test)
t.Run("invoices hide the other environment and count what is hidden", func(t *testing.T) {
out := e.get(e.operator, "/operator/billing/invoices?q="+e.marker)
if !strings.Contains(out, "LIVE-"+e.marker) {
t.Error("the live invoice must render under a live key")
}
if !strings.Contains(out, "NULL-"+e.marker) {
t.Error("an unverified invoice must render under either key")
}
if strings.Contains(out, "TEST-"+e.marker) {
t.Error("the test invoice must not render under a live key")
}
if !strings.Contains(out, "from test mode is not shown.") &&
!strings.Contains(out, "from test mode are not shown.") {
t.Errorf("absence line missing, got: %s", out)
}
if !strings.Contains(out, "Show all") {
t.Error("the absence line must carry the switch")
}
if !strings.Contains(out, "env=all") {
t.Error("the switch must link to the same view with env=all")
}
})
t.Run("the all state names itself and badges the other environment", func(t *testing.T) {
out := e.get(e.operator, "/operator/billing/invoices?q="+e.marker+"&env=all")
for _, want := range []string{"LIVE-" + e.marker, "TEST-" + e.marker, "NULL-" + e.marker} {
if !strings.Contains(out, want) {
t.Errorf("the all state must render %q", want)
}
}
if !strings.Contains(out, "Showing all environments.") {
t.Error("the all state's line missing")
}
if !strings.Contains(out, "Show live only") {
t.Error("the all state must offer the way back under a live key")
}
if !strings.Contains(out, ">Test</span>") {
t.Error("a row from the other environment must carry the Test badge")
}
})
t.Run("the switch survives a search, the facet and a page", func(t *testing.T) {
for _, target := range []string{
"/operator/billing/invoices?q=" + e.marker + "&env=all&status=open",
"/operator/billing/invoices?q=" + e.marker + "&env=all&page=1",
"/operator/billing/invoices?env=all&per=10",
} {
out := e.get(e.operator, target)
if !strings.Contains(out, "Showing all environments.") {
t.Errorf("%s: dropped the all state", target)
}
if !strings.Contains(out, "env=all") {
t.Errorf("%s: the scaffold's URLs dropped env=all", target)
}
}
})
t.Run("payments, subscriptions and accounts filter the same way", func(t *testing.T) {
for _, view := range []struct{ path, noun string }{
{"/operator/billing/payments", "payment"},
{"/operator/billing/subscriptions", "subscription"},
{"/operator/billing/accounts", "billing account"},
} {
out := e.get(e.operator, view.path+"?q="+e.marker)
if !strings.Contains(out, view.noun) {
t.Errorf("%s: absence line missing its noun %q, got: %s", view.path, view.noun, out)
}
if !strings.Contains(out, "from test mode") {
t.Errorf("%s: absence line must name the other environment", view.path)
}
if !strings.Contains(out, "Show all") {
t.Errorf("%s: absence line must carry the switch", view.path)
}
}
// The accounts view's own row is stamped test, so under a live key
// it is hidden altogether.
if out := e.get(e.operator, "/operator/billing/accounts?q="+e.marker); strings.Contains(out, "BillingEnvAccount "+e.marker) {
t.Error("a billing account recorded in test must not render under a live key")
}
})
}
// TestMemberInvoicesFilterWithoutASwitch pins the operator-billing-views
// scenario "A member's invoices are filtered without a switch": the
// member's own view drops the other environment's rows and offers no line,
// no switch and no badge.
func TestMemberInvoicesFilterWithoutASwitch(t *testing.T) {
live, test := true, false
e := newBillingEnvEnv(t, "live")
e.seedInvoice("LIVE-"+e.marker, &live)
e.seedInvoice("TEST-"+e.marker, &test)
e.seedInvoice("NULL-"+e.marker, nil)
handler, err := server.NewMemberInvoicesHandler(server.MemberInvoicesConfig{
BillingQ: billing.New(e.database),
StripeQ: stripedb.New(e.database),
AuthConfig: &auth.Config{SessionManager: scs.New()},
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
StripeMode: "live",
})
if err != nil {
t.Fatalf("NewMemberInvoicesHandler: %v", err)
}
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
// The member handler reads its own session manager, so drive it with a
// session that manager issued.
sm := scs.New()
authCfg := &auth.Config{SessionManager: sm}
handler.AuthConfig = authCfg
ctx, err := sm.Load(context.Background(), "")
if err != nil {
t.Fatalf("session load: %v", err)
}
sm.Put(ctx, "authenticated", true)
sm.Put(ctx, "org_id", e.orgID)
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/partials/member/invoices", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
out := rec.Body.String()
if !strings.Contains(out, "LIVE-"+e.marker) || !strings.Contains(out, "NULL-"+e.marker) {
t.Error("the member must see the live and the unverified invoice")
}
if strings.Contains(out, "TEST-"+e.marker) {
t.Error("the member must not see the other environment's invoice")
}
for _, unwanted := range []string{"env=all", "Show all", "Showing all environments", ">Test</span>"} {
if strings.Contains(out, unwanted) {
t.Errorf("the member view must carry no environment switch or badge, found %q", unwanted)
}
}
}
// TestSyncProductToStripeCreatesAgainWhenUnreachable pins the
// product-management scenarios "Sync action is a no-op when already pending
// (in-flight) or synced" and "Sync creates the object again when the mapping
// is unreachable" (task 3.4): the refusal fires only while the mapping
// agrees and is not stale; otherwise the mappings are rewritten pending and
// both creates are enqueued, so the executor's new mapping replaces the
// unreachable id.
func TestSyncProductToStripeCreatesAgainWhenUnreachable(t *testing.T) {
live, test := true, false
cases := []struct {
name string
livemode *bool
syncStatus string
wantRefusal bool
}{
{"recorded in the key's own environment refuses", &live, "synced", true},
{"an unverified mapping refuses", nil, "synced", true},
{"recorded in test under a live key creates again", &test, "synced", false},
{"a stale mapping creates again", &live, "stale", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
e := newBillingEnvEnv(t, "live")
productID := e.scan(
`INSERT INTO core.products (name, lifecycle_status, is_active, is_public)
VALUES ($1,'published',true,true) RETURNING product_id`,
"SyncEnv Plan "+e.marker)
priceID := e.scan(
`INSERT INTO core.prices (product_id, currency, unit_amount, recurring_interval, is_active, is_default)
VALUES ($1,'usd',1000,'month',true,true) RETURNING price_id`, productID)
e.exec(`INSERT INTO stripe.product_mappings (product_id, stripe_product_id, sync_status, livemode)
VALUES ($1,$2,$3,$4)`, productID, "prod_"+productID, tc.syncStatus, nullBool(tc.livemode))
e.exec(`INSERT INTO stripe.price_mappings (price_id, stripe_price_id, sync_status, livemode)
VALUES ($1,$2,$3,$4)`, priceID, "price_"+priceID, tc.syncStatus, nullBool(tc.livemode))
req := httptest.NewRequestWithContext(e.operator, http.MethodPost,
"/partials/operator/products/"+productID+"/sync-stripe", nil)
rec := httptest.NewRecorder()
e.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
// The refusal is a toast, so it rides the HX-Trigger header
// rather than the re-rendered body.
out := rec.Header().Get("HX-Trigger")
enqueued := e.outboxCount(productID)
if tc.wantRefusal {
if !strings.Contains(out, "This price is already synced to Stripe.") {
t.Errorf("want the already-synced refusal, got: %s", out)
}
if enqueued != 0 {
t.Errorf("outbox entries = %d, want 0: a refusal enqueues nothing", enqueued)
}
return
}
if strings.Contains(out, "This price is already synced to Stripe.") {
t.Error("an id the key cannot reach must not be called already synced")
}
if enqueued != 2 {
t.Errorf("outbox entries = %d, want 2 (product and price creates)", enqueued)
}
// The unreachable ids are cleared and both mappings are pending,
// so the executors' new ids land in their place.
if got := e.mappingState(`SELECT sync_status FROM stripe.price_mappings WHERE price_id=$1`, priceID); got != "pending" {
t.Errorf("price mapping sync_status = %q, want pending", got)
}
if got := e.mappingState(`SELECT coalesce(stripe_price_id,'') FROM stripe.price_mappings WHERE price_id=$1`, priceID); got != "" {
t.Errorf("stripe_price_id = %q, want the unreachable id cleared", got)
}
if got := e.mappingState(`SELECT coalesce(stripe_product_id,'') FROM stripe.product_mappings WHERE product_id=$1`, productID); got != "" {
t.Errorf("stripe_product_id = %q, want the unreachable id cleared", got)
}
})
}
}
// outboxCount counts the Stripe catalog-sync entries enqueued for one
// product, the evidence the action did or did not re-drive the sync.
func (e *billingEnvEnv) outboxCount(productID string) int {
e.t.Helper()
var n int
if err := e.database.QueryRowContext(context.Background(),
`SELECT count(*) FROM core.outbox
WHERE provider = 'stripe'
AND (payload->>'product_id' = $1
OR (payload->>'price_id')::uuid IN (SELECT price_id FROM core.prices WHERE product_id = $1::uuid))`,
productID).Scan(&n); err != nil {
e.t.Fatalf("count outbox: %v", err)
}
return n
}
func (e *billingEnvEnv) mappingState(query, arg string) string {
e.t.Helper()
var s string
if err := e.database.QueryRowContext(context.Background(), query, arg).Scan(&s); err != nil {
e.t.Fatalf("read mapping state: %v", err)
}
return s
}
// TestAbsenceLineCountsUnderTheSearch pins what the absence line's number
// means: the rows this view would have shown had the environment filter not
// applied, under the search in force. The line is about what the operator
// is looking at, so a search that matches none of the other environment's
// rows renders no line at all, and one that matches three names three.
func TestAbsenceLineCountsUnderTheSearch(t *testing.T) {
live, test := true, false
e := newBillingEnvEnv(t, "live")
e.seedInvoice("VIS-"+e.marker, &live)
e.seedInvoice("HID-"+e.marker+"-1", &test)
e.seedInvoice("HID-"+e.marker+"-2", &test)
e.seedInvoice("HID-"+e.marker+"-3", &test)
t.Run("a search matching none of the hidden rows renders no line", func(t *testing.T) {
out := e.get(e.operator, "/operator/billing/invoices?q=VIS-"+e.marker)
if !strings.Contains(out, "VIS-"+e.marker) {
t.Fatal("the search must still find the live invoice")
}
if strings.Contains(out, "from test mode") {
t.Error("nothing this search would have shown is hidden, so no line renders")
}
if strings.Contains(out, "Show all") {
t.Error("no line means no switch")
}
})
t.Run("a search matching some of them names their count", func(t *testing.T) {
out := e.get(e.operator, "/operator/billing/invoices?q=HID-"+e.marker)
if !strings.Contains(out, "3 invoices from test mode are not shown.") {
t.Errorf("want the count under the search, got: %s", out)
}
})
t.Run("a search matching one names it in the singular", func(t *testing.T) {
out := e.get(e.operator, "/operator/billing/invoices?q=HID-"+e.marker+"-2")
if !strings.Contains(out, "1 invoice from test mode is not shown.") {
t.Errorf("want the singular line, got: %s", out)
}
})
}
// TestAllStateAlwaysOffersTheWayBack pins the one state the no-line rule
// does not cover: env=all renders its line whether or not anything is out
// of mode, because the line is the only way back to the key's own
// environment and an operator who switched and then searched would
// otherwise be stranded there.
func TestAllStateAlwaysOffersTheWayBack(t *testing.T) {
live := true
e := newBillingEnvEnv(t, "live")
e.seedInvoice("ONLYLIVE-"+e.marker, &live)
if out := e.get(e.operator, "/operator/billing/invoices?q="+e.marker); strings.Contains(out, "from test mode") {
t.Error("the default state hides nothing here, so it renders no line")
}
out := e.get(e.operator, "/operator/billing/invoices?q="+e.marker+"&env=all")
if !strings.Contains(out, "Showing all environments.") {
t.Errorf("the all state must name itself with nothing out of mode, got: %s", out)
}
if !strings.Contains(out, "Show live only") {
t.Error("the all state must always carry the way back under a live key")
}
}
// TestInvoicesPagerTotalCountsOnlyShownRows pins operator-billing-views'
// "the pager's total SHALL count only the rows shown": the exclusion is a
// predicate on the paged query, so count(*) OVER() never counts a row the
// filter holds back, and the all state counts every row it renders.
func TestInvoicesPagerTotalCountsOnlyShownRows(t *testing.T) {
live, test := true, false
e := newBillingEnvEnv(t, "live")
e.seedInvoice("PGR-"+e.marker+"-1", &live)
e.seedInvoice("PGR-"+e.marker+"-2", &live)
e.seedInvoice("PGR-"+e.marker+"-3", nil)
e.seedInvoice("PGR-"+e.marker+"-4", &test)
e.seedInvoice("PGR-"+e.marker+"-5", &test)
out := e.get(e.operator, "/operator/billing/invoices?q=PGR-"+e.marker)
if !strings.Contains(out, "Showing 13 of 3") {
t.Errorf("the filtered pager must total the three rows it shows, got: %s", out)
}
all := e.get(e.operator, "/operator/billing/invoices?q=PGR-"+e.marker+"&env=all")
if !strings.Contains(all, "Showing 15 of 5") {
t.Errorf("the all state must total every row it shows, got: %s", all)
}
}
@@ -122,12 +122,13 @@ func TestProductDetailComposite(t *testing.T) {
Form: edit.Form,
Readiness: ProductReadinessVM{
Purchasable: false,
Missing: []string{"Payment processing"},
Missing: []string{"payment processing"},
StripeConfigured: true,
CanSyncStripe: true,
SyncLabel: "Sync to Stripe",
Rows: []ProductReadinessRow{
{Label: "Active price", State: "met"},
{Label: "Payment processing", State: "unmet", Detail: "use Sync to Stripe"},
{Label: "Payment processing", State: "unmet", Detail: "register it for payment"},
},
},
},
@@ -154,7 +155,7 @@ func TestProductDetailComposite(t *testing.T) {
`<dd class="col-sm-9 mb-0">Published</dd>`,
`<option value="set-9" selected>Sample Set</option>`, // the assigned set is the select's chosen option
"Purchasability", // readiness panel
"Sync to Stripe", // actionable affordance (CanSyncStripe)
"Sync to Stripe", // actionable affordance: the control carries SyncLabel
`<h2 class="h5 mb-0">Prices`, // prices section
"$10.00 USD", // a price row
} {
@@ -221,7 +221,7 @@ func TestEntitlementSetRulesTableAtRest(t *testing.T) {
`<tr class="app-rules-group"><th scope="rowgroup" colspan="5" class="small fw-semibold text-body-secondary">Boolean</th></tr>`,
`<th scope="col">Reduction policy</th>`,
`<td>Force reduce</td>`,
`<th scope="col" class="text-end">Actions</th>`,
`<th scope="col" class="text-end"><span class="visually-hidden">Actions</span></th>`,
`<td class="text-end">`,
`>Edit</button>`,
`>Remove</button>`,
+272 -26
View File
@@ -6,12 +6,18 @@ package server
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/instance"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
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/workflows/queues"
"go.temporal.io/sdk/temporal"
)
// The Stripe provider page (design D24; integration-settings ADDED
@@ -58,49 +64,116 @@ type StripeIntegrationData struct {
InboundEventsHeader SectionHeader
InboundEvents InboundEvents
InboundEventEntries []DeadLetterEntry
// EnvironmentCheckHeader/EnvironmentCheck carry the "Environment
// check" section (stripe-environment-stamp D3): where the read-back of
// the mapped Stripe ids stands under the key in force, and the control
// that runs it again. Rendered only when Stripe is configured, since
// there is no key to read anything back under otherwise.
EnvironmentCheckHeader SectionHeader
EnvironmentCheck EnvironmentCheck
}
// EnvironmentCheck is the Environment check section's state: one resting
// line naming where the read-back stands, and the control beside it
// (stripe-environment-stamp D3).
type EnvironmentCheck struct {
Line string
}
// InboundEvents is the health of Stripe's inbound webhook events: how many
// have exhausted their retries. Received, retrying and processing rows need
// no operator and are not counted; only dead-lettered ones are.
// have exhausted their retries, and how many arrived from the environment
// the configured key cannot reach. Received, retrying and processing rows
// need no operator and are not counted.
type InboundEvents struct {
DeadLetter int64
Available bool
// Refused counts the events captured from the other Stripe environment
// (stripe-environment-stamp D6). They are not a failure an operator
// retries: nothing here will ever process them, and the count is how a
// deployment learns an old endpoint is still pointed at it.
Refused int64
// RefusedLine names where those events came from and which key they
// met. Empty when none has been refused, so the section says nothing
// about an environment nothing arrived from.
RefusedLine string
Available bool
}
// NeedsAttention reports whether inbound events hold work that will not
// resolve on its own.
func (e InboundEvents) NeedsAttention() bool { return e.DeadLetter > 0 }
// loadInboundEvents counts Stripe's dead-lettered webhook events. Raw SQL
// against core.webhook_events, as loadDeadLetterEntries does for the outbox;
// a nil db or a failed probe leaves Available false.
func loadInboundEvents(ctx context.Context, db *sql.DB, logger *slog.Logger) InboundEvents {
// Quiet reports whether the section has nothing to report: no event has
// exhausted its retries and none has arrived from the other environment.
func (e InboundEvents) Quiet() bool { return e.DeadLetter == 0 && e.Refused == 0 }
// loadInboundEvents counts Stripe's dead-lettered and refused webhook
// events. Raw SQL against core.webhook_events, as loadDeadLetterEntries
// does for the outbox; a nil db or a failed probe leaves Available false.
// keyMode is the environment the configured key is in, which is what makes
// the refused line sayable: with two environments, an event the key
// refused came from the other one.
func loadInboundEvents(ctx context.Context, db *sql.DB, keyMode string, logger *slog.Logger) InboundEvents {
if db == nil {
return InboundEvents{}
}
var n int64
var deadLetter, refused int64
if err := db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM core.webhook_events WHERE provider = 'stripe' AND status = 'dead_letter'`,
).Scan(&n); err != nil {
logger.Warn("inbound events: dead-letter count failed", slog.Any("error", err))
`SELECT COUNT(*) FILTER (WHERE status = 'dead_letter'),
COUNT(*) FILTER (WHERE status = 'refused')
FROM core.webhook_events WHERE provider = 'stripe'`,
).Scan(&deadLetter, &refused); err != nil {
logger.Warn("inbound events: status count failed", slog.Any("error", err))
return InboundEvents{}
}
return InboundEvents{DeadLetter: n, Available: true}
return InboundEvents{
DeadLetter: deadLetter,
Refused: refused,
RefusedLine: refusedEventsLine(refused, keyMode),
Available: true,
}
}
// loadInboundDeadLetterEntries lists Stripe's dead-lettered webhook events
// for the Inbound events table, newest first, capped like the outbox table.
// OperationType carries the event type (e.g. "invoice.paid").
// refusedEventsLine states the refused events in the two environments'
// own words: the one they came from and the one the key is in. No key
// configured refuses nothing, so there is no line to write.
func refusedEventsLine(refused int64, keyMode string) string {
if refused == 0 || keyMode == "" {
return ""
}
eventMode := stripedb.ModeTest
if keyMode == stripedb.ModeTest {
eventMode = stripedb.ModeLive
}
return fmt.Sprintf("%d %s arrived in %s mode under a %s key.",
refused, pluralize(refused, "event", "events"), eventMode, keyMode)
}
// refusedEventDetail is what a refused row says in the table's detail
// column: it carries no error, because nothing failed. The event came from
// the environment the key cannot read, and was kept rather than processed
// (stripe-environment-stamp D6).
const refusedEventDetail = "Refused, mode mismatch"
// loadInboundDeadLetterEntries lists Stripe's dead-lettered and refused
// webhook events for the Inbound events table, capped like the outbox
// table. OperationType carries the event type (e.g. "invoice.paid").
//
// Dead-lettered rows sort ahead of refused ones, and each group is newest
// first. The two statuses share a table but not a reader's stake: a
// dead-lettered event needs an operator, while a refused one is recorded
// and finished with. An old endpoint still delivering to a moved key can
// refuse events by the hundred, and under one time ordering that flood
// pushes every dead-lettered row past the cap.
func loadInboundDeadLetterEntries(ctx context.Context, db *sql.DB, logger *slog.Logger) []DeadLetterEntry {
if db == nil {
return nil
}
rows, err := db.QueryContext(ctx,
`SELECT event_type, COALESCE(error_message, ''), retry_count, updated_at
`SELECT event_type, COALESCE(error_message, ''), retry_count, updated_at, status
FROM core.webhook_events
WHERE provider = 'stripe' AND status = 'dead_letter'
ORDER BY updated_at DESC
WHERE provider = 'stripe' AND status IN ('dead_letter', 'refused')
ORDER BY (status = 'dead_letter') DESC, updated_at DESC
LIMIT $1`, deadLetterEntryLimit)
if err != nil {
logger.Warn("inbound events: dead-letter entries query failed", slog.Any("error", err))
@@ -111,10 +184,15 @@ func loadInboundDeadLetterEntries(ctx context.Context, db *sql.DB, logger *slog.
for rows.Next() {
var e DeadLetterEntry
var updatedAt time.Time
if err := rows.Scan(&e.OperationType, &e.ErrorMessage, &e.Attempts, &updatedAt); err != nil {
var status string
if err := rows.Scan(&e.OperationType, &e.ErrorMessage, &e.Attempts, &updatedAt, &status); err != nil {
logger.Warn("inbound events: dead-letter entry scan failed", slog.Any("error", err))
return entries
}
if status == "refused" {
e.Refused = true
e.ErrorMessage = refusedEventDetail
}
e.UpdatedAt = updatedAt.Format("Jan 2, 2006 3:04 PM")
entries = append(entries, e)
}
@@ -177,6 +255,12 @@ type DeadLetterEntry struct {
ErrorMessage string
Attempts int32
UpdatedAt string
// Refused marks a row the table lists for a reason other than
// exhausted retries: an inbound event captured from the other Stripe
// environment. It carries no alarm styling, because no operator can
// move it and none should try (stripe-environment-stamp D6). Outbox
// entries never set it.
Refused bool
}
// deadLetterEntryLimit caps the Delivery queue section's table so a
@@ -224,6 +308,95 @@ func loadDeadLetterEntries(ctx context.Context, db *sql.DB, logger *slog.Logger)
return entries
}
// environmentCheckDateFormat is the page's existing date format, shared
// with the two dead-letter tables above.
const environmentCheckDateFormat = "Jan 2, 2006 3:04 PM"
// loadEnvironmentCheck states where the read-back of the mapped Stripe ids
// stands, in one line (stripe-environment-stamp D3). Four states, each
// with its own fact to carry:
//
// - no record, and nothing running: the check has never run here;
// - a record with a start and no finish: a run is under way, and the
// line says how many ids it is reading back;
// - a finished record under the key in force: when it ran and what it
// found;
// - a finished record under another key: the key moved since, so the
// counts describe ids nobody has confirmed under this one, and the
// line says how many are waiting.
//
// fingerprint is the digest of the key this process holds. A failed read
// degrades to the never-run line rather than to nothing: the section still
// offers the control, which is the way out of any of these states.
func loadEnvironmentCheck(ctx context.Context, db *sql.DB, fingerprint string, logger *slog.Logger) EnvironmentCheck {
if db == nil {
return EnvironmentCheck{Line: environmentNeverChecked}
}
raw, found, err := instance.NewStore(db).GetJSON(ctx, instance.StripeEnvironmentCheck)
if err != nil {
logger.Warn("environment check: record read failed", slog.Any("error", err))
return EnvironmentCheck{Line: environmentNeverChecked}
}
rec, ok := stripedb.DecodeEnvironmentCheckRecord(raw, found)
if !ok || rec.StartedAt == nil {
return EnvironmentCheck{Line: environmentNeverChecked}
}
// Only the state that needs a count pays for one.
var mapped int64
switch {
case rec.Running():
mapped = countMappedStripeIDs(ctx, db, false, logger)
case rec.KeyFingerprint != fingerprint:
mapped = countMappedStripeIDs(ctx, db, true, logger)
}
return EnvironmentCheck{Line: environmentCheckLine(rec, fingerprint, mapped)}
}
// environmentNeverChecked is the line for a deployment where the read-back
// has not run: no record, an unreadable one, or one written before a run
// ever started.
const environmentNeverChecked = "Not checked yet."
// environmentCheckLine writes the resting line for a record that exists.
// mapped is the count the state needs: the ids a run in progress is
// reading back, or the ids left unverified after the key moved. The
// states that need no count ignore it.
func environmentCheckLine(rec stripedb.EnvironmentCheckRecord, fingerprint string, mapped int64) string {
switch {
case rec.StartedAt == nil:
return environmentNeverChecked
case rec.Running():
return fmt.Sprintf("Checking %d Stripe %s under the current key.",
mapped, pluralize(mapped, "id", "ids"))
case rec.KeyFingerprint != fingerprint:
return fmt.Sprintf("API key changed since the last check. %d %s unverified.",
mapped, pluralize(mapped, "mapping", "mappings"))
}
return fmt.Sprintf("Last checked %s. %d verified, %d stale.",
rec.FinishedAt.Local().Format(environmentCheckDateFormat), rec.Verified(), rec.Stale)
}
// countMappedStripeIDs counts the product and price mappings holding a
// Stripe id, or, with unverifiedOnly, the subset no check has confirmed
// under the key in force. Raw SQL across the stripe schema, the way
// loadInboundEvents reads core.webhook_events: the two counts exist for
// one line of copy each and are not worth a generated query. A failed
// count reads zero, which makes the line say less rather than wrong.
func countMappedStripeIDs(ctx context.Context, db *sql.DB, unverifiedOnly bool, logger *slog.Logger) int64 {
query := `SELECT (SELECT COUNT(*) FROM stripe.product_mappings WHERE stripe_product_id IS NOT NULL)
+ (SELECT COUNT(*) FROM stripe.price_mappings WHERE stripe_price_id IS NOT NULL)`
if unverifiedOnly {
query = `SELECT (SELECT COUNT(*) FROM stripe.product_mappings WHERE stripe_product_id IS NOT NULL AND verified_at IS NULL)
+ (SELECT COUNT(*) FROM stripe.price_mappings WHERE stripe_price_id IS NOT NULL AND verified_at IS NULL)`
}
var n int64
if err := db.QueryRowContext(ctx, query).Scan(&n); err != nil {
logger.Warn("environment check: mapping count failed", slog.Any("error", err))
return 0
}
return n
}
// stripeModeLabel names the mode derived from the Stripe API key at boot
// (server.Config.StripeMode). A deployment with no key configured reads
// "Test mode": nothing it shows is live money, and the page states
@@ -242,21 +415,23 @@ func (h *OperatorPartialsHandler) GetStripeIntegrationPage(w http.ResponseWriter
modeLabel := stripeModeLabel(h.StripeMode)
bodyData := StripeIntegrationData{
Configured: configured,
Missing: missing,
ModeLabel: modeLabel,
Description: stripedb.ProviderSource().ProviderManifest().Description,
DeliveryQueueHeader: SectionHeader{Title: "Delivery queue"},
InboundEventsHeader: SectionHeader{Title: "Inbound events"},
Configured: configured,
Missing: missing,
ModeLabel: modeLabel,
Description: stripedb.ProviderSource().ProviderManifest().Description,
EnvironmentCheckHeader: SectionHeader{Title: "Environment check"},
DeliveryQueueHeader: SectionHeader{Title: "Delivery queue"},
InboundEventsHeader: SectionHeader{Title: "Inbound events"},
}
var iq integration.Querier
if h.Database != nil {
iq = integration.New(h.Database)
}
bodyData.EnvironmentCheck = loadEnvironmentCheck(r.Context(), h.Database, h.StripeKeyFingerprint, h.Logger)
bodyData.DeliveryQueue = loadDeliveryQueue(r.Context(), iq, h.Logger)
bodyData.DeliveryQueueEntries = loadDeadLetterEntries(r.Context(), h.Database, h.Logger)
bodyData.InboundEvents = loadInboundEvents(r.Context(), h.Database, h.Logger)
bodyData.InboundEvents = loadInboundEvents(r.Context(), h.Database, h.StripeMode, h.Logger)
bodyData.InboundEventEntries = loadInboundDeadLetterEntries(r.Context(), h.Database, h.Logger)
page := h.buildOperatorPageData(r)
@@ -266,3 +441,74 @@ func (h *OperatorPartialsHandler) GetStripeIntegrationPage(w http.ResponseWriter
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// environmentCheckWait bounds how long the control's request waits on the
// run before answering. A catalog of a few dozen ids settles inside it, so
// the common answer is the counts; a larger one answers that the check
// started and the section says the rest on the next load. The wait is
// short because it is a request holding a worker, not a job.
const environmentCheckWait = 3 * time.Second
// PostStripeEnvironmentCheck handles
// POST /partials/operator/integrations/stripe/environment-check: the
// provider page's "Check now" control (stripe-environment-stamp
// D3). It starts the same workflow boot starts, under the same id, so an
// operator pressing it while the boot-started run is still going is
// refused by Temporal and told so.
//
// It waits a few seconds on the result rather than none: an operator who
// pressed the control wants the outcome, and a catalog this size usually
// has one by then. The section re-renders either way, so the page never
// disagrees with the record.
func (h *OperatorPartialsHandler) PostStripeEnvironmentCheck(w http.ResponseWriter, r *http.Request) {
if !h.StripeConfigured {
http.Error(w, "Stripe is not configured.", http.StatusNotFound)
return
}
if h.TemporalClient == nil {
fireErrorToast(w, "Temporal is unavailable.")
h.renderStripeEnvironmentCheck(w, r)
return
}
run, err := stripewf.StartEnvironmentCheck(r.Context(), h.TemporalClient, queues.Main, stripewf.EnvironmentCheckTriggerOperator)
if err != nil {
if temporal.IsWorkflowExecutionAlreadyStartedError(err) {
fireSuccessToast(w, "Check running.")
h.renderStripeEnvironmentCheck(w, r)
return
}
h.Logger.Error("failed to start the stripe environment check workflow", slog.Any("error", err))
fireErrorToast(w, "Could not start the check.")
h.renderStripeEnvironmentCheck(w, r)
return
}
waitCtx, cancel := context.WithTimeout(r.Context(), environmentCheckWait)
defer cancel()
var result stripewf.EnvironmentCheckResult
// Get returns an error for two different facts: the wait ran out while
// the run went on, and the run itself failed. Only the deadline means
// the check is still going, so the two are told apart by the wait
// context rather than reported as one.
switch err := run.Get(waitCtx, &result); {
case err == nil:
fireSuccessToast(w, fmt.Sprintf("%d checked, %d stale.", result.Checked, result.Stale))
case errors.Is(waitCtx.Err(), context.DeadlineExceeded):
fireSuccessToast(w, "Check started.")
default:
// The failure itself goes to the log, not the toast: a workflow
// error carries the activity's wrapped Go error, which is the
// leak docs/operator-ux-conventions.md §4 keeps out of the UI.
h.Logger.Error("the stripe environment check failed", slog.Any("error", err))
fireErrorToast(w, "The check failed.")
}
h.renderStripeEnvironmentCheck(w, r)
}
// renderStripeEnvironmentCheck re-renders the Environment check section
// alone, which is the whole of what the control changes.
func (h *OperatorPartialsHandler) renderStripeEnvironmentCheck(w http.ResponseWriter, r *http.Request) {
h.Templates.Render(w, "operator_integration_stripe_environment.html",
loadEnvironmentCheck(r.Context(), h.Database, h.StripeKeyFingerprint, h.Logger))
}
@@ -6,17 +6,26 @@ package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/alexedwards/scs/v2"
"github.com/spf13/viper"
"github.com/stretchr/testify/mock"
"go.temporal.io/api/serviceerror"
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/mocks"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
stripewf "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/workflows"
)
// stripeConfigs is the installed-integration fixture for the Stripe
@@ -286,3 +295,277 @@ func TestGetStripeIntegrationPageConfigured(t *testing.T) {
t.Errorf("a nil database must degrade the delivery queue to unavailable, got:\n%s", out)
}
}
// TestStripeEnvironmentCheckLines covers the Environment check section's
// four resting lines (stripe-environment-stamp D3): the read-back has
// never run, one is under way, one finished under the key in force, and
// one finished under a key that has since been replaced.
func TestStripeEnvironmentCheckLines(t *testing.T) {
at := time.Date(2026, 9, 18, 15, 4, 0, 0, time.Local)
running := stripedb.EnvironmentCheckRecord{KeyFingerprint: "abc123", StartedAt: &at}
finished := stripedb.EnvironmentCheckRecord{
KeyFingerprint: "abc123", StartedAt: &at, FinishedAt: &at, Checked: 36, Stale: 2,
}
moved := finished
moved.KeyFingerprint = "deadbeef"
for _, tc := range []struct {
name string
rec stripedb.EnvironmentCheckRecord
fingerprint string
mapped int64
want string
}{
{"never run", stripedb.EnvironmentCheckRecord{}, "abc123", 0, "Not checked yet."},
{"under way", running, "abc123", 36, "Checking 36 Stripe ids under the current key."},
{"finished under this key", finished, "abc123", 0, "Last checked Sep 18, 2026 3:04 PM. 34 verified, 2 stale."},
{"finished under another key", moved, "abc123", 34, "API key changed since the last check. 34 mappings unverified."},
{"one id under way", running, "abc123", 1, "Checking 1 Stripe id under the current key."},
{"one mapping unverified", moved, "abc123", 1, "API key changed since the last check. 1 mapping unverified."},
} {
t.Run(tc.name, func(t *testing.T) {
if got := environmentCheckLine(tc.rec, tc.fingerprint, tc.mapped); got != tc.want {
t.Errorf("environmentCheckLine = %q, want %q", got, tc.want)
}
})
}
}
// TestStripeEnvironmentCheckSection renders the section and asserts it
// carries its resting line and its one outline control, and that an
// unconfigured deployment renders neither: there is no key to read
// anything back under.
func TestStripeEnvironmentCheckSection(t *testing.T) {
tmpl := parseOperatorPartials(t)
render := func(data StripeIntegrationData) string {
t.Helper()
data.ModeLabel = "Test mode"
data.DeliveryQueueHeader = SectionHeader{Title: "Delivery queue"}
data.InboundEventsHeader = SectionHeader{Title: "Inbound events"}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "operator_integration_stripe.html", data); err != nil {
t.Fatalf("render: %v", err)
}
return buf.String()
}
out := render(StripeIntegrationData{
Configured: true,
EnvironmentCheckHeader: SectionHeader{Title: "Environment check"},
EnvironmentCheck: EnvironmentCheck{Line: "Last checked Sep 18, 2026 3:04 PM. 34 verified, 2 stale."},
})
for _, want := range []string{
"Environment check",
"Last checked Sep 18, 2026 3:04 PM. 34 verified, 2 stale.",
"Check now",
`hx-post="/partials/operator/integrations/stripe/environment-check"`,
`id="stripe-environment-check"`,
"btn-outline-secondary",
} {
if !strings.Contains(out, want) {
t.Errorf("environment check section missing %q, got:\n%s", want, out)
}
}
out = render(StripeIntegrationData{
Configured: false,
Missing: []string{"stripe-api-key"},
EnvironmentCheckHeader: SectionHeader{Title: "Environment check"},
EnvironmentCheck: EnvironmentCheck{Line: "Not checked yet."},
})
if strings.Contains(out, "Check now") {
t.Errorf("an unconfigured deployment must not offer the check, got:\n%s", out)
}
}
// TestStripeInboundRefusedEvents covers the Inbound events section's half
// of stripe-environment-stamp D6: the count line renders only when events
// have been refused, and a refused row says why rather than carrying an
// error nothing produced.
func TestStripeInboundRefusedEvents(t *testing.T) {
tmpl := parseOperatorPartials(t)
render := func(data StripeIntegrationData) string {
t.Helper()
data.Configured = true
data.ModeLabel = "Live mode"
data.DeliveryQueueHeader = SectionHeader{Title: "Delivery queue"}
data.DeliveryQueue = DeliveryQueue{Available: true}
data.EnvironmentCheckHeader = SectionHeader{Title: "Environment check"}
data.EnvironmentCheck = EnvironmentCheck{Line: "Not checked yet."}
data.InboundEventsHeader = SectionHeader{Title: "Inbound events"}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "operator_integration_stripe.html", data); err != nil {
t.Fatalf("render: %v", err)
}
return buf.String()
}
out := render(StripeIntegrationData{
InboundEvents: InboundEvents{
Refused: 3,
RefusedLine: refusedEventsLine(3, "live"),
Available: true,
},
InboundEventEntries: []DeadLetterEntry{
{OperationType: "invoice.paid", ErrorMessage: refusedEventDetail, UpdatedAt: "Sep 18, 2026 3:04 PM", Refused: true},
},
})
for _, want := range []string{
"3 events arrived in test mode under a live key.",
"Refused, mode mismatch",
`<code class="text-nowrap">invoice.paid</code>`,
// The column holds a refused row's reason as well as a
// dead-lettered one's error, so its header names neither.
"<th>Detail</th>",
} {
if !strings.Contains(out, want) {
t.Errorf("inbound events section missing %q, got:\n%s", want, out)
}
}
if strings.Contains(out, "Inbound events are processing normally") {
t.Error("the quiet line must not render alongside refused events")
}
if strings.Contains(out, `<tr class="table-danger">`) {
t.Error("a refused row is not an operator's alarm; no alarm styling")
}
// No refused event, no line.
out = render(StripeIntegrationData{InboundEvents: InboundEvents{Available: true}})
if strings.Contains(out, "arrived in") {
t.Errorf("the refused line must not render when nothing was refused, got:\n%s", out)
}
if !strings.Contains(out, "Inbound events are processing normally.") {
t.Errorf("a quiet section must say so, got:\n%s", out)
}
}
// TestRefusedEventsLine pins the line's wording, which names the
// environment the events came from and the one the key is in.
func TestRefusedEventsLine(t *testing.T) {
for _, tc := range []struct {
refused int64
keyMode string
want string
}{
{3, "live", "3 events arrived in test mode under a live key."},
{1, "live", "1 event arrived in test mode under a live key."},
{2, "test", "2 events arrived in live mode under a test key."},
{0, "live", ""},
{3, "", ""},
} {
if got := refusedEventsLine(tc.refused, tc.keyMode); got != tc.want {
t.Errorf("refusedEventsLine(%d, %q) = %q, want %q", tc.refused, tc.keyMode, got, tc.want)
}
}
}
// TestPostStripeEnvironmentCheck covers the control's four outcomes
// (stripe-environment-stamp D3). run.Get reports two different facts
// through one error, so the outcomes the operator is told apart are: the
// run finished inside the wait, the wait ran out while the run went on,
// the run failed, and Temporal refused the start because the id is already
// held.
func TestPostStripeEnvironmentCheck(t *testing.T) {
newHandler := func(t *testing.T, temporalClient client.Client) *OperatorPartialsHandler {
t.Helper()
h, err := NewOperatorPartialsHandler(OperatorPartialsConfig{
StripeMode: "test",
StripeConfigured: true,
TemporalClient: temporalClient,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
AuthConfig: &auth.Config{SessionManager: scs.New()},
IntegrationConfigs: stripeConfigs,
})
if err != nil {
t.Fatalf("NewOperatorPartialsHandler: %v", err)
}
return h
}
// toast reads the message the handler put on the wire, which is the
// whole of what the operator is told.
toast := func(t *testing.T, w *httptest.ResponseRecorder, key string) string {
t.Helper()
var fired map[string]string
if err := json.Unmarshal([]byte(w.Header().Get("HX-Trigger")), &fired); err != nil {
t.Fatalf("HX-Trigger %q: %v", w.Header().Get("HX-Trigger"), err)
}
return fired[key]
}
// post drives the handler with a request whose context carries the
// given budget, so the deadline case costs the test that long and not
// the handler's own three seconds.
post := func(t *testing.T, h *OperatorPartialsHandler, budget time.Duration) *httptest.ResponseRecorder {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), budget)
t.Cleanup(cancel)
r := httptest.NewRequestWithContext(ctx, "POST",
"/partials/operator/integrations/stripe/environment-check", nil)
w := httptest.NewRecorder()
h.PostStripeEnvironmentCheck(w, r)
if w.Code != 200 {
t.Fatalf("want 200, got %d, body:\n%s", w.Code, w.Body.String())
}
return w
}
withRun := func(t *testing.T, run *mocks.WorkflowRun) client.Client {
t.Helper()
c := &mocks.Client{}
c.On("ExecuteWorkflow", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(run, nil)
return c
}
t.Run("the run finishes inside the wait", func(t *testing.T) {
run := &mocks.WorkflowRun{}
run.On("Get", mock.Anything, mock.Anything).
Run(func(args mock.Arguments) {
*(args.Get(1).(*stripewf.EnvironmentCheckResult)) = stripewf.EnvironmentCheckResult{Checked: 36, Stale: 2}
}).Return(nil)
w := post(t, newHandler(t, withRun(t, run)), time.Minute)
if got := toast(t, w, "showSuccessToast"); got != "36 checked, 2 stale." {
t.Errorf("toast = %q, want the run's counts", got)
}
})
t.Run("the wait runs out while the run goes on", func(t *testing.T) {
run := &mocks.WorkflowRun{}
run.On("Get", mock.Anything, mock.Anything).
Return(func(ctx context.Context, _ interface{}) error {
<-ctx.Done()
return ctx.Err()
})
w := post(t, newHandler(t, withRun(t, run)), 50*time.Millisecond)
if got := toast(t, w, "showSuccessToast"); got != "Check started." {
t.Errorf("toast = %q, want the started line", got)
}
})
t.Run("the run fails", func(t *testing.T) {
run := &mocks.WorkflowRun{}
run.On("Get", mock.Anything, mock.Anything).
Return(errors.New("stripe get product prod_x: rate limit"))
w := post(t, newHandler(t, withRun(t, run)), time.Minute)
if got := toast(t, w, "showSuccessToast"); got != "" {
t.Errorf("a failed run reported success: %q", got)
}
// The failure's own text is logged, not shown: the toast says the
// check failed, which is the fact the operator acts on.
if got := toast(t, w, "showErrorToast"); got != "The check failed." {
t.Errorf("error toast = %q, want the failure line", got)
}
})
t.Run("a run already holds the id", func(t *testing.T) {
c := &mocks.Client{}
c.On("ExecuteWorkflow", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(nil, serviceerror.NewWorkflowExecutionAlreadyStarted(
"already started", "start-request", "run-id"))
w := post(t, newHandler(t, c), time.Minute)
if got := toast(t, w, "showSuccessToast"); got != "Check running." {
t.Errorf("toast = %q, want the running line", got)
}
})
}
+1 -1
View File
@@ -403,7 +403,7 @@ func (h *OperatorHandler) loadOverviewIntegrationsCard(ctx context.Context) Over
func (h *OperatorHandler) loadOverviewStripeFacts(ctx context.Context) OverviewStripeFacts {
mode := stripeModeLabel(h.StripeMode)
queue := loadDeliveryQueue(ctx, h.IntegrationQ, h.Logger)
inbound := loadInboundEvents(ctx, h.Database, h.Logger)
inbound := loadInboundEvents(ctx, h.Database, h.StripeMode, h.Logger)
return OverviewStripeFacts{
ModeLabel: mode,
Queued: queue.Queued(),
+37 -22
View File
@@ -111,9 +111,14 @@ type OperatorPartialsHandler struct {
// ("test", "live", or "" when no key is configured): the provider
// page's mode label and the billing views' test-mode banner read it
// (see Config.StripeMode).
StripeMode string
StripeConfigured bool
TemporalClient client.Client
StripeMode string
// StripeKeyFingerprint is the digest of the API key this process runs
// under, never the key (see Config.StripeKeyFingerprint). The
// Environment check section reads it to tell a check that ran under
// this key from one that ran under another.
StripeKeyFingerprint string
StripeConfigured bool
TemporalClient client.Client
// Registry is the domains allocation API, for the operator Domains
// surface (operator_domains.go). Nil disables that page's data rather
// than the page: it renders the "registry unavailable" banner, the same
@@ -136,10 +141,15 @@ type OperatorPartialsConfig struct {
AuthConfig *auth.Config
StripeDashboardURL string
StripeMode string
StripeConfigured bool
TemporalClient client.Client
Registry *domains.Registry
IntegrationConfigs []IntegrationConfigInfo
// StripeKeyFingerprint is the digest of the API key this process runs
// under (see Config.StripeKeyFingerprint): the Environment check
// section compares it with the fingerprint the recorded check ran
// under, which is how the page knows the key moved since.
StripeKeyFingerprint string
StripeConfigured bool
TemporalClient client.Client
Registry *domains.Registry
IntegrationConfigs []IntegrationConfigInfo
}
// NewOperatorPartialsHandler creates a new OperatorPartialsHandler
@@ -220,21 +230,22 @@ func NewOperatorPartialsHandler(cfg OperatorPartialsConfig) (*OperatorPartialsHa
tmpl = tmpl.Funcs(template.FuncMap{"surfaceRoot": OperatorSurfaceRoot})
return &OperatorPartialsHandler{
EntitlementsQ: cfg.EntitlementsQ,
BillingQ: cfg.BillingQ,
StripeQ: cfg.StripeQ,
Database: cfg.Database,
IdentityQ: cfg.IdentityQ,
OrgQ: cfg.OrgQ,
Logger: cfg.Logger,
AuthConfig: cfg.AuthConfig,
Templates: NewSafeTemplates(tmpl, cfg.Logger),
IntegrationConfigs: cfg.IntegrationConfigs,
StripeDashboardURL: cfg.StripeDashboardURL,
StripeMode: cfg.StripeMode,
StripeConfigured: cfg.StripeConfigured,
TemporalClient: cfg.TemporalClient,
Registry: cfg.Registry,
EntitlementsQ: cfg.EntitlementsQ,
BillingQ: cfg.BillingQ,
StripeQ: cfg.StripeQ,
Database: cfg.Database,
IdentityQ: cfg.IdentityQ,
OrgQ: cfg.OrgQ,
Logger: cfg.Logger,
AuthConfig: cfg.AuthConfig,
Templates: NewSafeTemplates(tmpl, cfg.Logger),
IntegrationConfigs: cfg.IntegrationConfigs,
StripeDashboardURL: cfg.StripeDashboardURL,
StripeMode: cfg.StripeMode,
StripeKeyFingerprint: cfg.StripeKeyFingerprint,
StripeConfigured: cfg.StripeConfigured,
TemporalClient: cfg.TemporalClient,
Registry: cfg.Registry,
}, nil
}
@@ -331,6 +342,10 @@ func (h *OperatorPartialsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /partials/operator/products", h.requireOperatorRole(h.CreateProduct))
mux.HandleFunc("PUT /partials/operator/products/{productID}", h.requireOperatorRole(h.UpdateProduct))
mux.HandleFunc("POST /partials/operator/products/{productID}/sync-stripe", h.requireOperatorRole(h.SyncProductToStripe))
// The Stripe provider page's Environment check control
// (stripe-environment-stamp D3): starts the read-back of the mapped
// Stripe ids under the key in force and re-renders its section.
mux.HandleFunc("POST /partials/operator/integrations/stripe/environment-check", h.requireOperatorRole(h.PostStripeEnvironmentCheck))
mux.HandleFunc("GET /partials/operator/products/{productID}/readiness", h.requireOperatorRole(h.GetProductReadiness))
// Duplicate-name warning (design D4): live hx-get from the create/edit
// forms' name fields.
@@ -229,6 +229,27 @@ func TestProductEditRendersPurchasabilityPanel(t *testing.T) {
}
})
// stripe-environment-stamp D5: a price whose recorded id this key
// cannot reach offers the control again, labelled for what pressing it
// does, and opens no confirm modal.
t.Run("a mapping the key cannot reach relabels the control", func(t *testing.T) {
vm := buildVMRules(product("published", true, true), shapeFor(true, "recurring"),
stampedMapping(boolPtr(false), true, "synced"), 1, "live")
out := renderProductEdit(t, OperatorProductEditData{Product: base, Readiness: vm})
if !strings.Contains(out, "Create in live") {
t.Errorf("expected the control to read \"Create in live\", got: %s", out)
}
if strings.Contains(out, "Sync to Stripe\n") {
t.Error("the control must not still read \"Sync to Stripe\"")
}
if !strings.Contains(out, "Synced in test, key is live") {
t.Error("the Payment processing row must state why the recorded id is no good")
}
if strings.Contains(out, "confirm-modal") || strings.Contains(out, "hx-confirm") {
t.Error("creating in the current environment destroys nothing, so no confirm modal")
}
})
t.Run("stripe not configured renders the empty-state, not the Sync button", func(t *testing.T) {
vm := buildVM(product("published", true, true), shapeFor(true, "recurring"),
PriceReadiness{HasActivePrice: true, PriceID: "x", StripeConfigured: false}, false, "", nil)
@@ -241,3 +262,6 @@ func TestProductEditRendersPurchasabilityPanel(t *testing.T) {
}
})
}
// boolPtr is stampedMapping's livemode argument in literal form.
func boolPtr(b bool) *bool { return &b }
+80 -15
View File
@@ -5,6 +5,7 @@ package server
import (
"context"
"database/sql"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
@@ -33,6 +34,16 @@ type PriceReadiness struct {
// mapped" means "Stripe is off", not "needs syncing"; the panel renders an
// explanatory state, not an action.
StripeConfigured bool
// Livemode, VerifiedAt and SyncStatus are the mapping's environment
// stamp (stripe-environment-stamp D2), carried as read so the readiness
// panel can say which environment holds the id and whether the check
// has reached it. They are meaningless unless StripeMapped is true.
// StripeMapped still means only "a Stripe id is recorded": whether that
// id can be reached under the current key is a question about the key,
// which this struct is deliberately ignorant of.
Livemode sql.NullBool
VerifiedAt sql.NullTime
SyncStatus string
}
// computePriceReadiness resolves the product's tracked price and its Stripe
@@ -67,6 +78,7 @@ func computePriceReadiness(ctx context.Context, billingQ billing.Querier, stripe
if err != nil {
return r
}
r.Livemode, r.VerifiedAt, r.SyncStatus = mapping.Livemode, mapping.VerifiedAt, mapping.SyncStatus
switch {
case mapping.StripePriceID.Valid:
r.StripeMapped = true
@@ -108,6 +120,7 @@ func priceReadinessFromBatches(stripeConfigured bool, prices []billing.Price, ma
if !ok {
return r
}
r.Livemode, r.VerifiedAt, r.SyncStatus = mapping.Livemode, mapping.VerifiedAt, mapping.SyncStatus
switch {
case mapping.StripePriceID.Valid:
r.StripeMapped = true
@@ -228,6 +241,12 @@ type ProductReadinessVM struct {
// CanRetryStripe is true when the last Stripe sync terminally failed
// (dead-lettered) and can be re-driven. Drives the "Retry" button.
CanRetryStripe bool
// SyncLabel is the Sync control's label: "Sync to Stripe" for a price
// that was never synced, and "Create in live" / "Create in test" for one
// whose recorded id the current key cannot reach, where the press
// creates the object again rather than registering it for the first
// time (stripe-environment-stamp D5).
SyncLabel string
// SyncPending mirrors the in-flight sync state at the top level so the
// template can emit live-update polling attributes only while a sync is
// actually in flight.
@@ -296,17 +315,34 @@ type readinessInputs struct {
stripeMode string
}
// syncedPaymentDetail names the Stripe account a synced price lives in, so
// the surface where syncing is triggered says which mode it reached
// (design D3). An unknown mode names nothing rather than guessing.
func syncedPaymentDetail(stripeMode string) string {
switch stripeMode {
case "live":
return "Synced, live"
case "test":
return "Synced, test"
// syncedPaymentDetail names what the surface where syncing is triggered
// knows about the recorded Stripe id: which environment it lives in, and
// whether a key in stripeMode can reach it (design D3, and
// stripe-environment-stamp D5). met is false for the two states where the
// id is out of reach, which makes the Payment processing row unmet and
// the verdict name it.
//
// An unknown mode names nothing rather than guessing, and reaches
// everything: a deployment whose key the console could not classify has no
// environment to compare a stamp against.
func syncedPaymentDetail(pr PriceReadiness, stripeMode string) (detail string, met bool) {
if stripeMode == "" {
return "Synced", true
}
return "Synced"
other := internalstripe.OtherMode(stripeMode)
switch {
case pr.SyncStatus == internalstripe.SyncStatusStale:
// The check asked Stripe for this id under the current key and was
// answered resource_missing, so the key's environment is the one
// the id is not in; the recorded flag may agree and still be no
// help, since two Stripe environments both report livemode false.
return "Not found in " + stripeMode + " mode", false
case !internalstripe.MappingAgrees(pr.Livemode, stripeMode):
return "Synced in " + other + ", key is " + stripeMode, false
case !pr.Livemode.Valid || !pr.VerifiedAt.Valid:
return "Synced, unverified", true
}
return "Synced, " + stripeMode, true
}
// buildProductReadinessVM assembles the readiness panel for one product from
@@ -321,6 +357,13 @@ func buildProductReadinessVM(in readinessInputs, syncFailed bool, syncError stri
setPresent, _ := shape.SetPresent.(bool)
hasRules := in.activeRules > 0
priced := shape.BillingShape != "unpriced"
// stripeReachable is the Payment processing row's answer to the second
// half of the mapping question: a recorded id the current key can
// actually reach (stripe-environment-stamp D5). It starts false and is
// set by the mapped branch below, so an unmapped, pending or failed
// price never counts as reachable; the purchasable verdict reads it
// beside pr.StripeMapped.
stripeReachable := false
var vm ProductReadinessVM
@@ -379,7 +422,10 @@ func buildProductReadinessVM(in readinessInputs, syncFailed bool, syncError stri
// Price + payment processing apply only to listed products.
priceRow := ProductReadinessRow{Label: "Active price"}
stripeRow := ProductReadinessRow{Label: "Payment processing"}
// MissingName is lowercase like every other row's, so the verdict reads
// "Incomplete; missing: payment processing" (stripe-environment-stamp
// delta, product-management) rather than restating the row's label.
stripeRow := ProductReadinessRow{Label: "Payment processing", MissingName: "payment processing"}
if !isPublic {
priceRow.State = "n/a"
priceRow.Detail = "Unlisted product; no price required."
@@ -391,9 +437,11 @@ func buildProductReadinessVM(in readinessInputs, syncFailed bool, syncError stri
switch {
case pr.StripeMapped:
stripeRow.State = "met"
stripeRow.Detail = syncedPaymentDetail(in.stripeMode)
detail, met := syncedPaymentDetail(pr, in.stripeMode)
stripeRow.State = state(met)
stripeRow.Detail = detail
stripeRow.DetailAlways = true
stripeReachable = met
case syncFailed:
stripeRow.State = "failed"
stripeRow.Detail = "The last Stripe sync failed"
@@ -424,8 +472,21 @@ func buildProductReadinessVM(in readinessInputs, syncFailed bool, syncError stri
vm.Rows = append(vm.Rows, providerRows...)
vm.StripeConfigured = pr.StripeConfigured
vm.CanSyncStripe = isPublic && pr.StripeConfigured && pr.HasActivePrice && !pr.StripeMapped && !pr.SyncPending && !syncFailed
// A mapped price whose id the key cannot reach offers the control
// again: pressing it creates the object in the environment the key is
// in and the new mapping replaces the unreachable id
// (stripe-environment-stamp D5).
unreachable := pr.StripeMapped && !stripeReachable
vm.CanSyncStripe = isPublic && pr.StripeConfigured && pr.HasActivePrice && (!pr.StripeMapped || unreachable) && !pr.SyncPending && !syncFailed
vm.CanRetryStripe = isPublic && pr.StripeConfigured && pr.HasActivePrice && !pr.StripeMapped && syncFailed
vm.SyncLabel = "Sync to Stripe"
if unreachable && in.stripeMode != "" {
// The label carries the fact a confirm modal would otherwise have
// to state, in the control itself: this press creates a new object,
// and names where. No modal, because creating in the current
// environment destroys nothing (design D5).
vm.SyncLabel = "Create in " + in.stripeMode
}
// Missing is computed from the purchasability preconditions only, before
// the member-catalog-visibility row below is appended: that row is a
@@ -457,7 +518,11 @@ func buildProductReadinessVM(in readinessInputs, syncFailed bool, syncError stri
}
if isPublic {
vm.Purchasable = published && gate.Active && setPresent && hasRules && priced && pr.StripeMapped && providersOK
// stripeReachable as well as StripeMapped: an id recorded in the
// environment the key is not in, or one the check could not fetch,
// is an id no member checkout can use, and checkout refuses it
// (stripe-environment-stamp D5).
vm.Purchasable = published && gate.Active && setPresent && hasRules && priced && pr.StripeMapped && stripeReachable && providersOK
} else {
vm.Purchasable = published && gate.Active && setPresent && hasRules && providersOK
}
+146 -1
View File
@@ -7,6 +7,7 @@ import (
"database/sql"
"strings"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
@@ -315,7 +316,7 @@ func TestBuildProductReadinessVM_InactiveAndListed(t *testing.T) {
t.Run("Payment processing detail names the Stripe mode", func(t *testing.T) {
for mode, want := range map[string]string{"live": "Synced, live", "test": "Synced, test", "": "Synced"} {
vm := buildVMRules(product("published", true, true), shapeForLadder(true, "recurring", 1), mapped, 1, mode)
vm := buildVMRules(product("published", true, true), shapeForLadder(true, "recurring", 1), verifiedIn(mode), 1, mode)
if got := rowDetail(vm, "Payment processing"); got != want {
t.Errorf("stripe-mode %q: Payment processing detail = %q, want %q", mode, got, want)
}
@@ -323,6 +324,150 @@ func TestBuildProductReadinessVM_InactiveAndListed(t *testing.T) {
})
}
// stampedMapping is a mapped PriceReadiness carrying one environment
// stamp: livemode as recorded (nil for a row written before the stamp)
// and verifiedAt set only where the environment check has read the id
// back (stripe-environment-stamp D2).
func stampedMapping(livemode *bool, verified bool, syncStatus string) PriceReadiness {
pr := PriceReadiness{
HasActivePrice: true,
PriceID: "p1",
StripeMapped: true,
StripeConfigured: true,
SyncStatus: syncStatus,
}
if livemode != nil {
pr.Livemode = sql.NullBool{Bool: *livemode, Valid: true}
}
if verified {
pr.VerifiedAt = sql.NullTime{Time: time.Now(), Valid: true}
}
return pr
}
// verifiedIn is a mapping recorded in mode and read back under it; the
// settled state of a product that has been synced and checked.
func verifiedIn(mode string) PriceReadiness {
if mode == "" {
return stampedMapping(nil, false, "synced")
}
live := mode == "live"
return stampedMapping(&live, true, "synced")
}
// TestBuildProductReadinessVM_MappingEnvironment pins the five Payment
// processing details of stripe-environment-stamp D5, which of them leave
// the row met, and what the two unmet ones do to the verdict, the Sync
// control's label and the affordance.
func TestBuildProductReadinessVM_MappingEnvironment(t *testing.T) {
live, test := true, false
pub := product("published", true, true)
shape := shapeForLadder(true, "recurring", 1)
cases := []struct {
name string
pr PriceReadiness
mode string
wantDetail string
wantState string
wantVerdict string
wantSyncable bool
wantLabel string
}{
{
name: "no stamp is unverified and met",
pr: stampedMapping(nil, false, "synced"),
mode: "live",
wantDetail: "Synced, unverified", wantState: "met",
wantVerdict: "Purchasable", wantLabel: "Sync to Stripe",
},
{
name: "agreeing but never read back is unverified and met",
pr: stampedMapping(&live, false, "synced"),
mode: "live",
wantDetail: "Synced, unverified", wantState: "met",
wantVerdict: "Purchasable", wantLabel: "Sync to Stripe",
},
{
name: "agreeing and read back names the environment",
pr: stampedMapping(&live, true, "synced"),
mode: "live",
wantDetail: "Synced, live", wantState: "met",
wantVerdict: "Purchasable", wantLabel: "Sync to Stripe",
},
{
name: "agreeing and read back under a test key",
pr: stampedMapping(&test, true, "synced"),
mode: "test",
wantDetail: "Synced, test", wantState: "met",
wantVerdict: "Purchasable", wantLabel: "Sync to Stripe",
},
{
name: "recorded in test under a live key is unmet",
pr: stampedMapping(&test, true, "synced"),
mode: "live",
wantDetail: "Synced in test, key is live", wantState: "unmet",
wantVerdict: "Incomplete; missing: payment processing",
wantSyncable: true, wantLabel: "Create in live",
},
{
name: "recorded in live under a test key is unmet",
pr: stampedMapping(&live, true, "synced"),
mode: "test",
wantDetail: "Synced in live, key is test", wantState: "unmet",
wantVerdict: "Incomplete; missing: payment processing",
wantSyncable: true, wantLabel: "Create in test",
},
{
name: "stale names the key's environment, not the mapping's",
pr: stampedMapping(&live, true, "stale"),
mode: "live",
wantDetail: "Not found in live mode", wantState: "unmet",
wantVerdict: "Incomplete; missing: payment processing",
wantSyncable: true, wantLabel: "Create in live",
},
{
name: "stale under a test key",
pr: stampedMapping(&test, true, "stale"),
mode: "test",
wantDetail: "Not found in test mode", wantState: "unmet",
wantVerdict: "Incomplete; missing: payment processing",
wantSyncable: true, wantLabel: "Create in test",
},
{
name: "an unclassified key compares nothing",
pr: stampedMapping(&test, true, "stale"),
mode: "",
wantDetail: "Synced", wantState: "met",
wantVerdict: "Purchasable", wantLabel: "Sync to Stripe",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
vm := buildVMRules(pub, shape, tc.pr, 1, tc.mode)
if got := rowDetail(vm, "Payment processing"); got != tc.wantDetail {
t.Errorf("Payment processing detail = %q, want %q", got, tc.wantDetail)
}
if got := rowState(vm, "Payment processing"); got != tc.wantState {
t.Errorf("Payment processing state = %q, want %q", got, tc.wantState)
}
if vm.Verdict != tc.wantVerdict {
t.Errorf("Verdict = %q, want %q", vm.Verdict, tc.wantVerdict)
}
if vm.Purchasable != (tc.wantState == "met") {
t.Errorf("Purchasable = %v for a %q row", vm.Purchasable, tc.wantState)
}
if vm.CanSyncStripe != tc.wantSyncable {
t.Errorf("CanSyncStripe = %v, want %v", vm.CanSyncStripe, tc.wantSyncable)
}
if vm.SyncLabel != tc.wantLabel {
t.Errorf("SyncLabel = %q, want %q", vm.SyncLabel, tc.wantLabel)
}
})
}
}
// rowDetail returns the Detail of the named precondition row, or "" if absent.
func rowDetail(vm ProductReadinessVM, label string) string {
for _, r := range vm.Rows {
+22 -13
View File
@@ -55,7 +55,13 @@ type Config struct {
// key in force (stripe-integration-infrastructure, "Stripe mode is
// derived from the API key").
StripeMode string
BaseURL string // Application base URL for redirects
// StripeKeyFingerprint is the SHA-256 digest of the API key, derived
// at boot beside the mode and never the key itself. The Stripe
// provider page's Environment check section compares it with the
// fingerprint the last check recorded, which is how the page says the
// key changed since (stripe-environment-stamp D3).
StripeKeyFingerprint string
BaseURL string // Application base URL for redirects
// AskFallbackURL is the optional legacy on-demand-TLS answerer
// (domains-ask-fallback-url; empty disables it). Registry misses — and
// only misses — are forwarded there, so a deployment can move off a
@@ -435,18 +441,19 @@ func Start(ctx context.Context, cfg Config) error {
// Register Operator HTMX partials handlers
operatorPartialsHandler, err := NewOperatorPartialsHandler(OperatorPartialsConfig{
EntitlementsQ: cfg.EntitlementsQ,
BillingQ: cfg.BillingQ,
StripeQ: cfg.StripeQ,
Database: cfg.Database,
IdentityQ: cfg.IdentityQ,
OrgQ: cfg.OrgQ,
Logger: cfg.Logger,
AuthConfig: authConfig,
StripeDashboardURL: cfg.StripeDashboardURL,
StripeMode: cfg.StripeMode,
StripeConfigured: cfg.StripeAPIKey != "" && cfg.StripeWebhookSecret != "",
TemporalClient: cfg.TemporalClient,
EntitlementsQ: cfg.EntitlementsQ,
BillingQ: cfg.BillingQ,
StripeQ: cfg.StripeQ,
Database: cfg.Database,
IdentityQ: cfg.IdentityQ,
OrgQ: cfg.OrgQ,
Logger: cfg.Logger,
AuthConfig: authConfig,
StripeDashboardURL: cfg.StripeDashboardURL,
StripeMode: cfg.StripeMode,
StripeKeyFingerprint: cfg.StripeKeyFingerprint,
StripeConfigured: cfg.StripeAPIKey != "" && cfg.StripeWebhookSecret != "",
TemporalClient: cfg.TemporalClient,
// The operator Domains surface reads and moderates claims through
// the registry, under the deployment's policy — the same policy the
// member surface allocates under, since the page prints it as the
@@ -481,6 +488,7 @@ func Start(ctx context.Context, cfg Config) error {
AuthConfig: authConfig,
Logger: cfg.Logger,
Database: cfg.Database,
StripeMode: cfg.StripeMode,
})
if err != nil {
cfg.Logger.Error("failed to set up Member Products handler", slog.Any("error", err))
@@ -494,6 +502,7 @@ func Start(ctx context.Context, cfg Config) error {
StripeQ: cfg.StripeQ,
AuthConfig: authConfig,
Logger: cfg.Logger,
StripeMode: cfg.StripeMode,
})
if err != nil {
cfg.Logger.Error("failed to set up Member Invoices handler", slog.Any("error", err))
+51
View File
@@ -25,6 +25,31 @@ type MockBackend struct {
List []*stripe.Subscription // returned for List
Invoice *stripe.Invoice // returned for invoice create-preview / upcoming
Err error // if set, every call returns it
// Products and Prices answer product.Get and price.Get, keyed by
// Stripe id, for the environment check's read-back of the mapped ids.
Products map[string]*stripe.Product
Prices map[string]*stripe.Price
// DefaultProduct and DefaultPrice answer an id neither map names, so a
// test can pin the few ids it cares about without standing in for
// every mapping row the shared database happens to hold. Nil means an
// unnamed id answers resource_missing, Stripe's own word for an object
// that does not exist under the key it was asked with.
DefaultProduct *stripe.Product
DefaultPrice *stripe.Price
// ObjectErrs answers one Stripe id with one error, ahead of the maps:
// a rate limit, a network fault, anything a missing object cannot say.
ObjectErrs map[string]error
}
// resourceMissing is Stripe's answer for an id that does not exist under
// the key it was asked with, built here so a test need not construct one.
func resourceMissing(id string) error {
return &stripe.Error{
Type: stripe.ErrorTypeInvalidRequest,
Code: stripe.ErrorCodeResourceMissing,
HTTPStatusCode: 404,
Msg: "No such object: " + id,
}
}
func (m *MockBackend) Call(method, path, key string, params stripe.ParamsContainer, v stripe.LastResponseSetter) error {
@@ -37,6 +62,32 @@ func (m *MockBackend) Call(method, path, key string, params stripe.ParamsContain
payload = &stripe.SubscriptionList{Data: m.List}
case strings.HasPrefix(path, "/v1/subscriptions/"):
payload = m.Sub
case strings.HasPrefix(path, "/v1/products/"):
id := strings.TrimPrefix(path, "/v1/products/")
if err, ok := m.ObjectErrs[id]; ok {
return err
}
obj, ok := m.Products[id]
if !ok {
obj = m.DefaultProduct
}
if obj == nil {
return resourceMissing(id)
}
payload = obj
case strings.HasPrefix(path, "/v1/prices/"):
id := strings.TrimPrefix(path, "/v1/prices/")
if err, ok := m.ObjectErrs[id]; ok {
return err
}
obj, ok := m.Prices[id]
if !ok {
obj = m.DefaultPrice
}
if obj == nil {
return resourceMissing(id)
}
payload = obj
case path == "/v1/invoices/create_preview" || path == "/v1/invoices/upcoming":
if m.Invoice == nil {
return errors.New("stripetest: no invoice preview configured")
@@ -0,0 +1,435 @@
# Design
## Context
Stripe keeps two separate worlds of data: a live one where real money
moves, and a test one for rehearsal, which since 2024 is any number of
sandboxes. An id created in one world does not exist in another. The
console stores a Stripe id and a sync status on each of the eight mapping
tables in `internal/integrations/stripe/store/migrations/00001_init.sql`
and records nothing about the world that made the id, so the moment the
API key moves, every mapping is a dangling pointer the console still
calls synced (the issue "The console does not record which Stripe
environment a synced object lives in", `status/issues.md`).
Three facts shape everything below:
- **Every Stripe object carries `livemode`**, a boolean, on the SDK type
the console receives: `Product`, `Price`, `Customer`, `Subscription`,
`Event`, `Invoice`, `PaymentMethod`, `PaymentIntent` (stripe-go
v81.4.0). `SubscriptionItem` does not carry it; an item's world is its
parent subscription's.
- **A legacy test key and every sandbox report `livemode: false`.** The
flag cannot tell two sandboxes apart. Only a read under the current
key can: an id that used to resolve answers `resource_missing`
(`ErrorCodeResourceMissing` in the SDK's `error.go`).
- **Who writes the mappings.** Three outbox executors in
`internal/integrations/stripe/workflows/outbox.go` (`create_stripe_customer`,
`create_stripe_product`, `create_stripe_price`), each holding the
object Stripe returned from `New`; `internal/fulfillment/reconcile.go`,
which refetches the subscription with `subscription.Get` and writes the
subscription and item mappings; and the webhook projections
(`webhook_invoice.go`, `webhook_payment_method.go`), which parse the
stored object payload into hand-rolled structs that omit `livemode`
today. Nothing writes the envelope's `Livemode`: the webhook handler
(`internal/integrations/stripe/web/webhook.go`) stores `event.Data.Raw`,
the object, and discards the envelope.
The maintainer took the decisions on 2026-09-19, one per turn, from a
research pass and a decision sheet; the sheet's corrections to the
proposal are applied in D9. The picks, in the order they were made:
| | Decision | Pick |
|---|---|---|
| M1 | Where the stamp lives | nullable `livemode BOOLEAN` on all eight mapping tables; NULL is unverified; the migration writes nothing |
| M2 | What stamps subscriptions and items | `reconcile.go`, from the fetched subscription's flag, for both tables |
| M3 | Verification | boot compares a key fingerprint; the read-back is one GET per product and price |
| M3b | Where the last-verified record lives | `core.instance_settings`, one key |
| M3c | How the check runs | boot starts a Temporal workflow when the fingerprint changed; the provider page's control re-runs it |
| M4 | A disagreeing mapping | not synced, on every consumer |
| M5 | Events from the other world | `provider_environment TEXT` on `core.webhook_events`, provider vocabulary; a disagreeing event is stored `refused` |
| M6 | The billing banner | unchanged; the billing views show the current environment's rows, with a switch to all |
## D1: The stamp is a nullable boolean, written by whoever holds the object
Each of the eight `stripe.*_mappings` tables gains `livemode BOOLEAN`
(nullable) in one migration that adds the column and writes nothing.
Every existing row holds NULL, which every reader treats as unverified:
not live, not test, not a disagreement. No row is guessed into a world.
A boolean, not a text domain, because the SDK's flag is a boolean on
every object type and a text column invites a fourth value (a sandbox
id) that Stripe never supplies. All eight tables, not the five the sync
manages, because the three projection-side tables (invoices, payments,
payment methods) are the ones the billing views read in D7.
Who writes the flag, and from where:
| Table | Writer | Source of the flag |
|---|---|---|
| `customer_mappings` | `executeCreateStripeCustomer` | the returned `*stripe.Customer` |
| `product_mappings` | `executeCreateStripeProduct` | the returned `*stripe.Product` |
| `price_mappings` | `executeCreateStripePrice` | the returned `*stripe.Price` |
| `subscription_mappings` | `upsertSubscription` in `reconcile.go` | the refetched `*stripe.Subscription` |
| `subscription_item_mappings` | `reconcileItems` in `reconcile.go` | the parent subscription's flag (M2) |
| `invoice_mappings`, `payment_mappings` | `webhook_invoice.go` | the invoice object's own `livemode`, added to `webhookInvoicePayload` |
| `payment_method_mappings` | `webhook_payment_method.go` | the payment method object's own `livemode`, added to `webhookPaymentMethodPayload` |
The projection structs are hand-parsed subsets of the stored object
payload, which carries `livemode` on every object type the console
subscribes to; adding the field to the struct is the whole change on that
side. The webhook product, price and customer handlers, which update
mappings on `*.updated` events, write the flag the same way from the
object they parse.
`product_mappings` and `price_mappings` also gain `verified_at
TIMESTAMPTZ` (nullable), the two tables the check in D3 reads back.
The other six carry no verification column: nothing reads them back,
and their flag is a fact recorded at write time.
## D2: What "unverified", "agrees", "disagrees" and "stale" mean
Four words carry the whole change, and each has one definition:
- **Unverified**: `livemode IS NULL`, or, on a product or price
mapping, `verified_at IS NULL` while `livemode` is set and agrees with
the key. An unverified row blocks nothing: checkout, sync, reconcile,
the plan change and the sweep treat it as synced, and readiness names
it `Synced, unverified`. This is the state every existing row is in
after the migration, and the state a stamped row returns to after a
key change until the check runs.
- **Agrees**: `livemode` is set and equals the key's mode
(`ModeForKey` in `internal/integrations/stripe/stripe.go`, derived
once at boot into `server.Config.StripeMode`). One helper in the Stripe
store package, `MappingAgrees(livemode sql.NullBool, keyMode string)
bool`, returns true for NULL and for a match, so every consumer asks
one question.
- **Disagrees**: `livemode` is set and differs from the key's mode. The
object cannot be reached under the current key. D5 says what each
consumer does.
- **Stale**: the check in D3 asked Stripe for the id under the current
key and Stripe answered `resource_missing`. `sync_status = 'stale'`
joins `pending`, `synced` and `deleted`; the column is bare text on
all eight tables, so the vocabulary lives in Go (the store's known
states and the readiness branches), not in a check constraint. The
recorded `livemode` stays as it was, because the object still exists
in its own world. Stale is
the only way a same-mode move (sandbox to sandbox, sandbox to legacy
test) is detected, and a stale row behaves as a disagreeing one on
every consumer.
## D3: The key fingerprint, the instance setting, and the check
**The fingerprint.** A SHA-256 digest of the raw API key, digest only,
never the key, the shape `internal/auth/auth.go` already uses for the
PKCE verifier. Boot computes it beside `ModeForKey`.
**The record.** One key in `core.instance_settings`,
`stripe.environment_check`, holding a JSON object:
`{"key_fingerprint": "...", "started_at": ..., "finished_at": ...,
"checked": 36, "stale": 2}`. `internal/instance/settings.go` gains a
second `settingKind`, a JSON-object kind with a typed accessor beside
`GetBool`/`SetBool`; the JSONB column and `updated_at` already fit, so no
migration. Not `core.integration_config_overrides`, which is boot-applied
and override-only, so a value written at request time would take effect
at restart; not a ninth Stripe-schema table for one row, the shape of
`provider_configs` dropped unused in migration 2.
**Boot.** With Stripe configured, boot reads the setting. When the
recorded fingerprint differs from the current one, or no record exists
(the first boot after this change, and every deployment's first boot),
boot clears `verified_at` on every product and price mapping and starts
the check workflow. Nothing about the read-back happens on the boot
path itself: the workflow is started, not awaited, so boot stays as
fast as it is, and a deployment whose Stripe is down at boot still
comes up. When the fingerprint matches, boot does nothing.
**The workflow.** `StripeEnvironmentCheckWorkflow` in
`internal/integrations/stripe/workflows`, one activity, workflow id
`stripe-environment-check` so a second start while one runs is
deduplicated by Temporal rather than by the console. The activity lists
every product and price mapping with a Stripe id, then for each one
issues `product.Get` or `price.Get` under the current key, one at a
time (a few hundred rows on the largest deployment today, far under
Stripe's rate limit, and sequential so the console never bursts):
- the object resolves: the row takes the object's `livemode` and
`verified_at = now()`, and a `stale` row that resolves again returns
to `synced`;
- `resource_missing`: the row takes `sync_status = 'stale'` and
`verified_at = now()`, and one log line names it (`stripe: mapping id
missing under the current key` with the table, the row id, the Stripe
id and the mode);
- any other error: the activity fails and Temporal retries it with its
policy; the rows it already wrote stay written, and a rerun rewrites
them the same way.
The activity heartbeats per row. On completion it writes the setting
with the fingerprint, the times and the counts, and logs `stripe:
environment check complete` with `checked`, `stale`, `mode` and the
first eight characters of the fingerprint. The start logs `stripe:
environment check started` with `mappings`, `mode`, the fingerprint
prefix and `trigger=boot` or `trigger=operator`.
**The operator control.** The Stripe provider page gains a section,
`Environment check`, under the existing readiness section, with a
resting line and one outline control, `Check now` (relabeled from `Check
Stripe ids` at the maintainer's review, 2026-09-20: one verb across the
heading, the line and the control, and the reason to press it stays in the
resting line, which names the key change), a declared
action trigger posting to a new route on the page. The control starts
the same workflow with `trigger=operator`; it exists so an operator who
fixed something on the Stripe side can re-verify without a restart, and
it is the only manual step in the change, never a step the change
depends on. The resting line reads, by state:
- no record yet, nothing running: `Not checked yet.`
- running: `Checking 36 Stripe ids under the current key.`
- finished under the current fingerprint: `Last checked Sep 18, 2026
3:04 PM. 34 verified, 2 stale.` (the date format the page already
uses)
- finished under another fingerprint (the window between a key change
and the boot-started run reaching its end): `API key changed since the
last check. 34 mappings unverified.`
The success toast on the control reads `36 checked, 2 stale.` when the
workflow finishes within the request's wait, and `Check started.` when
it does not. The page's Inbound events section is unchanged by this
decision; D6 adds to it.
**Customers are not read back.** The check covers products and prices,
the ids the catalog and checkout depend on. A customer id dangling after
a same-mode move surfaces at the member's first checkout as
`resource_missing` from `session.New`, the generic failure the member
sees today; a disagreeing customer mapping (different mode) is caught by
D5 without a read. Extending the check to customers is one more loop
over one more table and is left for a follow-up once the two-table run
has been observed in production; the risk is recorded in the Risks
section and logged as an issue at build time.
## D4: The proposal's five executors are three plus a read-back
Subscription and subscription item mappings are stamped by
`reconcile.go`, not by an outbox executor: `upsertSubscription` and
`reconcileItems` write both tables from a `subscription.Get` they
already perform on every call, and the console never creates a
subscription (Stripe Checkout does). Both tables take the parent's flag,
because `SubscriptionItem` carries none. Rejected: item consumers
joining to the parent at read time (the two tables can then disagree
whenever a row is written outside reconcile), and new executors for
objects the console does not create.
## D5: A disagreeing or stale mapping is not synced, on every consumer
Six consumers read a mapping id under the key. Each gains one branch on
`MappingAgrees` (and on `sync_status = 'stale'`), and each does the one
thing that is honest for its reader:
| Consumer | Today | With this change |
|---|---|---|
| Readiness "Payment processing" (`product_readiness.go`) | `Synced, live` / `Synced, test` from the key | names the recorded world and the disagreement; the row is unmet, the verdict `Incomplete; missing: payment processing` |
| Sync control (`SyncProductToStripe`, `operator_billing.go`) | refuses with `This price is already synced to Stripe.` whenever a Stripe id exists | refuses only when the mapping agrees and is not stale; otherwise creates the object again in the current world through the outbox, and the new mapping row replaces the old id; the control is relabelled for that case (below) |
| Member checkout (`billing.go`) | sends the old id; Stripe answers `resource_missing`; the member sees `failed to start checkout` | refuses before the Stripe call with the existing string for the same fact, `price not yet available in Stripe`; a disagreeing customer mapping is treated as absent and the customer created again |
| `reconcile.go` | calls Stripe and fails | skips the subscription and logs |
| `plan_change.go` | calls Stripe and fails | refuses the switch before the call and logs |
| `sweep.go` | calls Stripe and fails | skips the scheduled change before claiming its row and logs, so the row stays scheduled and fires once the mapping is created again |
A seventh reader, found at build time: the member catalog's
purchasability gate (`resolvePurchasable` and `resolveTierPrice` in
`member_products.go`) shares `computePriceReadiness` with the readiness
panel, and product-management says the two never disagree. It therefore
takes the same question: a price whose mapping the key cannot reach is
not purchasable, so an add-on is not listed and a plan tier's move
control renders disabled, instead of offering a checkout that
`HandleCheckout` would refuse.
Unverified is not a disagreement on any of them, so production keeps
working between the migration and the first check, and between a key
change and the boot-started run.
Readiness detail strings, one per state of the mapping:
- absent: unchanged, the row is unmet as today
- `livemode` NULL, or agrees with `verified_at` NULL: `Synced, unverified`
- agrees and verified: `Synced, live` / `Synced, test` (unchanged)
- disagrees: `Synced in test, key is live` / `Synced in live, key is test`
- stale: `Not found in live mode` / `Not found in test mode` (the key's
world, in the words the provider page and the overview already use for
it; maintainer, 2026-09-20)
The Sync control, when it will create again, is relabelled `Create in
live` (or `Create in test`) in place of `Sync to Stripe`, with no confirm
modal: the modal is the console's destructive idiom (design-system §3)
and creating an object in the current world destroys nothing, while the
readiness row beside the control already states why the old id is no
good. The decision sheet previewed a confirm sentence, `Sync creates a
new price in live.`; the label carries the same fact in the control
itself, the copy rule's preferred place.
Log lines, in the shape `reconcile.go` already logs:
`reconcile: subscription mapping unreachable under the current key; skipping`,
`plan_change: price mapping unreachable under the current key; skipping`,
`sweep: price mapping unreachable under the current key; skipping scheduled
change`, each with the row id, the Stripe id, `recorded_mode` (`live`,
`test`, or `stale`) and `key_mode`. The message is constant, so one log
search finds every skip whatever the two modes were; the decision sheet
previewed the modes inside the sentence, which would have read false
under a test key.
## D6: Events from the other world are captured and refused
`core.webhook_events` is provider-neutral: `provider`,
`provider_event_id`, `event_type` and `provider_event_at` hold each
provider's own values in neutral columns. It gains `provider_environment
TEXT` (nullable, no check constraint, like `event_type`), the provider's
own vocabulary; the Stripe handler writes `live` or `test` from the
envelope's `Livemode`, the value the handler discards today. Not a
`livemode` column, which would put one provider's word on the shared
shape (the maintainer's objection, 2026-09-19); not a JSON read of the
stored payload, which is the object and not the envelope, and whose
hand-rolled structs omit the field; not a side table in the Stripe
schema, two rows per event for one flag.
At insert, the handler compares the event's world with the key's mode.
A disagreeing event is stored with `status = 'refused'` and no workflow
starts; the endpoint answers 200 so Stripe stops redelivering.
`refused` stays outside `unfinishedStatuses`, so a redelivery of a
refused event answers 200 without restarting anything. Every other
event is unchanged.
The Stripe provider page's Inbound events section gains one line,
rendered only when the count is above zero: `3 events arrived in test
mode under a live key.` A refused row in that table carries the detail
`Refused, mode mismatch`. The log line: `stripe: webhook event mode
disagrees with the key; captured, not processed` with `event_id`,
`event_type`, `event_mode` and `key_mode`.
## D7: The billing views show the current world, with a switch to all
The banner is unchanged: `Stripe is in test mode. Figures on these pages
are test data.` under a test key, nothing under a live key. It becomes
true by construction, because the billing views only show rows from the
world the key is in.
Each billing view (billing accounts through customers, subscriptions,
invoices, payments, and the member's own invoices in
`member_invoices.go`) shows only rows whose mapping's `livemode` equals
the key's mode or is NULL. NULL rows show under either key, the same
rule as everywhere else. The operator views page through the core
billing queries and look each row's mapping up afterwards, so the
filter cannot be a predicate on the page query without breaking the
pager's totals; instead the view first asks the Stripe store for the
core ids whose mapping is recorded in the other world and hands the
core list query that exclusion, the shape the invoice-number search
already uses to feed matching ids into the invoices query. The operator
views take a query parameter, `env=all`, carried through the list
scaffold's URLs beside search, the facet and paging; the member view
takes none, since a member has no business with the other world's
figures.
The switch rides the absence line, not a facet pill: the scaffold
carries one facet per list and invoices and subscriptions spend it on
status. Above a view that has hidden rows, one muted line names them
and carries the switch: `12 invoices from test mode are not shown. Show
all` (the count is the rows the view would have shown under the same
search and facet, so a search that matches none of them renders no
line); in the all state the line always renders, `Showing all
environments. Show live only` (or `test only`), even when nothing is
out of mode, so the operator can always get back, and every row from
the other world carries
a badge, `Test` or `Live`, through the status-badge part: the map
already holds `live` in the success tone (the integrations page's mode
badge), and gains `test` in the secondary tone, the muted counterpart
the map uses for a state that is not the live one. When nothing is
hidden, neither the line
nor the switch renders. The absence line is a data-preservation fact
the copy rule keeps: the rows exist, they are not shown here, and the
line says which and how many.
Rejected: a mixed-ledger sentence on the banner (`Figures on these pages
mix test and live data.`), which names a defect on every billing page
and offers no verb; and a purge of the other world's projection rows,
which is destructive across five mapping tables and the core ledgers
behind them, and which the hidden rows keep possible as a later change
once the stamps have been verified in production.
## D8: The order things happen in on a deployment that moves
For a sandbox-to-live move (the maintainer's named case), in order:
1. The operator sets the live key and the live webhook secret together
(`docs/stripe.md` gains the section that says so) and restarts.
2. Boot derives `live`, finds the fingerprint differs, clears
`verified_at` on products and prices, starts the check.
3. Readiness rows read `Synced in test, key is live`, unmet, at once:
the recorded flag already disagrees. Checkout refuses those prices.
4. The check finishes within minutes; nothing it finds changes the
disagreeing rows, which stay disagreeing.
5. The operator opens each product and presses Sync; the outbox creates
the product and its prices in live and the mappings now read
`Synced, live` once verified.
6. The billing views show no rows until live events arrive; the line
says `12 invoices from test mode are not shown.` with the switch.
7. A test-mode event still arriving through an old endpoint is refused
and counted on the provider page.
For a sandbox-to-sandbox move, step 3 shows nothing, because both worlds
read test; step 4 marks the moved rows stale, and from then on they
behave as disagreeing rows: readiness `Not found in test mode`, checkout
refused, Sync creates again.
For wiki.cafe, which has run under its live key from the start: the
migration leaves every row NULL; the first boot after deploy finds no
fingerprint record, starts the check, and the products and prices come
back `Synced, live` verified without anyone clicking; the projection
rows stay NULL, which shows under the live key.
## D9: Corrections applied to the proposal
The decision sheet found seven things the proposal had wrong or open;
`proposal.md` is amended in the same commit as this design:
1. "five executors" is three outbox executors plus the reconcile
read-back (D4).
2. Subscription and item mappings are stamped by `reconcile.go`, not by
an executor (D4).
3. An item mapping records its parent's flag; `SubscriptionItem` has no
`Livemode` (D1, D4).
4. The projection-side mappings take the flag from the object's own
field inside the stored payload, not from "the webhook event's
`livemode`"; the envelope's mode goes to the events table (D1, D6).
5. The fingerprint record lives in `core.instance_settings`; the
overrides table is boot-applied and override-only (D3).
6. `plan_change.go` and `sweep.go` are consumers the Impact list missed
(D5).
7. The read-back runs at boot as a started workflow, not as boot work,
and the operator action re-runs it (D3).
And two decisions the proposal deferred to the design: the banner keeps
its sentence and the views filter instead (D7), and the events column is
provider-neutral (D6).
## Risks
- **A false stamp blocks a working product.** A row stamped with the
wrong world disagrees with a key that could reach it, and readiness,
checkout and the fulfillment paths refuse it until the check reruns.
The flag is written from the object Stripe returned, never derived
from the key, so the only path to a wrong stamp is a bug in a writer;
the writers' tests set `livemode` on their canned objects.
- **Customers are not read back** (D3). A same-mode move leaves customer
ids dangling until a checkout fails; logged as a follow-up.
- **Boot without Temporal reachable.** The check is started like the
entitlement recompute poller; if the start fails, boot logs it and
continues, and the provider page's control starts it later.
- **Production rollout.** The migration touches nine tables with a
nullable column each and no data; the first boot runs the check over
wiki.cafe's live products and prices, a few dozen GETs.
## Open for the maintainer's review
All four settled by the maintainer on 2026-09-20: the stale wording is
`Not found in live mode`; the member invoices view filters without a
switch; the Sync control relabels to `Create in live` with no confirm
modal; the `Test` badge takes the secondary tone beside the existing
green `Live`.
@@ -0,0 +1,35 @@
## Why
The console remembers a synced Stripe object as an id and a status and nothing else: `stripe.product_mappings`, `price_mappings`, `customer_mappings` and `subscription_mappings` carry no record of the environment the id was created in, and a sync is one create call at authoring time that nothing ever reads back. An id resolves only in the environment that created it (live, the legacy test mode, or one sandbox). So the moment the API key moves to another environment, every mapping becomes a dangling pointer the console still calls synced. The maintainer named the case on 2026-09-13: products authored in a sandbox, then the key set to the live account; and the reverse, a live start walked back to a sandbox. In both directions today the readiness row reads "Synced, live" or "Synced, test" from the current key rather than from where the object is; the Sync control refuses with "This price is already synced to Stripe."; a member checkout sends the old price id under the new key and Stripe answers `resource_missing`, which the member sees as "failed to start checkout"; the fulfillment reconcile fails the same way; and the billing banner's "Figures on these pages are test data" describes the key while the figures are ledger rows written under whatever key was configured when the webhooks arrived. `slice3-followup-fixes` derived the mode from the key, which is right, and left the mappings as they were. console.wiki.cafe is not exposed: its objects were created under the live key from the start.
## What Changes
- **Every mapping records its environment.** The three outbox executors write the `livemode` flag from the object Stripe returns onto the mapping row (customers, products, prices); the fulfillment reconcile writes it onto the subscription and subscription item mappings from the subscription it refetches (an item carries no flag of its own, so it records its parent's); the projection-side mappings (invoices, payments, payment methods) take it from the object's own `livemode` inside the stored payload. Rows that predate the column hold unverified until the read-back settles them; the migration guesses nothing.
- **A disagreeing mapping is not synced.** Where the recorded mode differs from the key's mode, or the read-back found the id missing: the readiness Payment processing row names the recorded mode and the disagreement instead of "Synced, live"; the Sync control creates the object again in the current environment instead of refusing; the member checkout refuses before calling Stripe; the reconcile, the plan change and the scheduled-change sweep skip and log.
- **A read-back verifies ids in the current environment.** Under the current key the console reads each synced product and price and marks a `resource_missing` mapping stale; this is the only way to detect a move between two sandboxes, which share `livemode` false. Boot compares a fingerprint of the key with the one the last check ran under and, when they differ, starts the check as a Temporal workflow in the background; the Stripe provider page states the outcome and offers a re-run.
- **Webhook events that disagree with the key are refused.** An event whose environment differs from the key's mode is captured, not processed, and surfaced on the Stripe provider page, so a signing secret left behind from the other environment is visible rather than silent. The events table is provider-neutral, so the column is `provider_environment`, in the provider's own vocabulary.
- **The billing views show the current environment's figures.** The banner keeps its sentence and becomes true by construction: each billing view shows only the rows recorded in the key's environment, one line names the rows from the other environment that are not shown and switches the view to all, and in that state each row from the other environment carries an environment badge. The design settles the copy.
## Capabilities
### New Capabilities
- None. The environment stamp and the read-back extend the existing Stripe capabilities.
### Modified Capabilities
- `stripe-integration-infrastructure`: mapping rows record `livemode`; the key fingerprint, the environment check workflow and its stale marking; webhook events record `provider_environment`, and one that disagrees with the key's mode is refused and surfaced.
- `stripe-product-catalog-sync`: the product and price executors stamp the mapping; the Sync control creates again when the mapping disagrees with the key or is stale.
- `stripe-customer-sync`: the customer executor stamps the mapping; a disagreeing customer mapping is created again at checkout.
- `stripe-subscription-creation`: the reconcile stamps the subscription and item mappings; checkout refuses a price whose mapping disagrees with the key's mode, before any Stripe call; the reconcile skips a disagreeing subscription.
- `stripe-subscription-management`: the member's paid-to-paid plan switch refuses a disagreeing or stale mapping before the Stripe call, and the scheduled-change sweep skips one before claiming the row, so the row stays scheduled.
- `product-management`: the readiness Payment processing detail reads the recorded mode and names unverified, disagreeing and stale mappings; the sync-from-readiness requirement's "already synced" condition becomes "synced in this environment".
- `operator-billing-views`: the billing views show the current environment's rows, name the rows they do not show, and switch to all with a badge per row from the other environment; the test-mode banner is unchanged.
## Impact
- Schema: one nullable `livemode` column on the eight `stripe.*_mappings` tables (unverified until settled), `verified_at` on the product and price mappings, a `stale` sync status, `provider_environment` on `core.webhook_events`, and one `core.instance_settings` key holding the fingerprint the last check ran under; sqlc regeneration for the stripe store and the core store.
- Code: `workflows/outbox.go` (three executors), `fulfillment/reconcile.go`'s read-back path, the webhook projections that upsert mappings, `SyncProductToStripe` and the readiness inputs in `internal/server`, `billing.go` checkout, `fulfillment/plan_change.go` and `sweep.go`, the webhook handler's insert, a check workflow and its boot start in `cmd/start.go`, the Stripe provider page (check section and control, refused-event count), the billing views and the member invoices view, the instance settings registry.
- Docs: `docs/stripe.md` gains a section on moving a deployment between Stripe environments (what to swap together: key, webhook secret; what the console does with the old mappings; what the check does and where to read it).
- Production: the migration leaves the existing live rows unverified; the first boot after deploy finds no fingerprint record and runs the check, which marks wiki.cafe's products and prices verified in live without an operator step. No behavior changes until the key changes.
- Decided before specs and tasks (design D1 to D7): the copy for an unverified, disagreeing or stale row, the check's trigger and cost, and the billing views' environment filter in place of a mixed-ledger banner sentence.
@@ -0,0 +1,41 @@
## MODIFIED Requirements
### Requirement: Add-on products displayed as table rows
The member catalog SHALL display, in one non-plan section separate from the ladder-grouped plans, **every published, public product that is not a tier on any active plan ladder and that can be bought**: a product whose default price is active, recurring, and Stripe-mapped (`resolvePurchasable` plus the recurring check), and whose mapping the current API key can reach (its recorded environment agrees with the key's mode, or is unverified, and the environment check has not marked it stale; stripe-integration-infrastructure, Requirement: Mappings record the environment that made their ids). A published, public non-tier product without such a price SHALL NOT be listed (maintainer, 2026-09-19: a catalog offers what can be bought; the operator's readiness panel on the product's own page reports the missing price or the unreachable mapping), so no row ever carries text where its purchase control would be. The earlier `display_category = 'addon'` membership gate stays dissolved: `display_category` remains presentation-only grouping metadata (Doc 41 §4.2, §6.7) carrying no behavioral weight; when a product carries a label it renders as a small grouping badge on its row, and the label neither includes nor excludes a product from the section. The section's heading adapts to what the page actually shows (a plan-less deployment's whole catalog is this section, and must not read as an appendix to plans that do not exist): beneath rendered plan sections it is headed "More products" with the line "Products you can add alongside your plan, or use on their own."; standing alone it is headed plain "Products" with no plan-referencing framing. A product that is a ladder tier renders in its plan section and SHALL NOT be duplicated into the non-plan section regardless of its label. Each entry SHALL show the product `name` and `description` and its purchase control.
#### Scenario: Non-tier purchasable products all render in the non-plan section
- **WHEN** published, public products with a purchasable price exist that are not tiers on any active ladder, with `display_category` values of `addon`, some other label, and blank
- **THEN** all of them render in the non-plan section, each with its purchase control
- **AND** a carried label renders as a grouping badge without affecting membership
#### Scenario: The heading adapts to the presence of plans
- **WHEN** plan sections render above the non-plan section
- **THEN** it is headed "More products" with its definition line
- **WHEN** no plans exist and the section is the whole catalog
- **THEN** it is headed plain "Products" and no plan-referencing copy renders
#### Scenario: No purchasable products
- **WHEN** no published, public non-tier product with a purchasable price exists
- **THEN** the non-plan section SHALL NOT be rendered
#### Scenario: A tier is not duplicated into the non-plan section
- **WHEN** a product is a tier in a `plan_ladder_tiers` row and also carries `display_category = 'addon'`
- **THEN** it renders in its plan ladder section only
- **AND** the non-plan section does not repeat it
#### Scenario: A product that cannot be bought is not listed
- **WHEN** a published, public non-tier product's default price is missing, one-time, or not Stripe-mapped
- **THEN** the product does not render in the non-plan section
- **AND** the operator's readiness panel for that product reports the missing price and says the catalog lists a non-tier product only while it has an active, synced, recurring price
#### Scenario: A product the key cannot reach is not listed
- **WHEN** a published, public non-tier product's default price is mapped, but its mapping records the other Stripe environment or is marked stale by the environment check
- **THEN** the product does not render in the non-plan section, and a plan tier in the same state renders its move control disabled
- **AND** the operator's readiness panel for that product names the unreachable mapping
@@ -0,0 +1,44 @@
## ADDED Requirements
### Requirement: Billing views show the current environment's rows
Every billing view that reaches a Stripe mapping SHALL show only the rows recorded in the environment the console derives from its API key, so the test-mode banner's sentence (Requirement: Billing views mark Stripe test mode) is true by construction rather than by an operator's care. Each such view (billing accounts through `stripe.customer_mappings`, subscriptions, invoices, payments, and the member's own invoices) SHALL show a row only when its mapping's `livemode` equals the key's mode or is NULL. A NULL row is unverified and SHALL show under either key, the same rule every consumer of the stamp follows (stripe-integration-infrastructure, Requirement: Mappings record the environment that made their ids). The operator views page through the core billing queries, so the filter SHALL be applied before paging (the view resolves the excluded ids in the Stripe store and hands the paged query that exclusion, as the invoice-number search hands it matching ids), and the pager's total SHALL count only the rows shown.
The operator views SHALL accept one query parameter, `env=all`, which shows every row, and the list scaffold SHALL carry it through its URLs beside search, the facet and paging, so a search, a page or a facet click keeps the view in the state the operator put it in. The member's own invoices view SHALL filter without a switch and SHALL accept no such parameter.
The switch rides an absence line rather than a facet pill, because the scaffold carries one facet per list and invoices and subscriptions spend it on status. Above a view holding rows the filter hides, one muted line SHALL name them and carry the switch: `12 invoices from test mode are not shown. Show all`, the count being the rows the view would have shown under the same search and facet, so a search that matches none of the hidden rows renders no line. In the all state the line SHALL always render, `Showing all environments. Show live only` under a live key and `Showing all environments. Show test only` under a test key, even when nothing is out of mode, so the operator can always return; and every row from the other environment SHALL carry a `Test` or `Live` badge rendered through the status-badge part: the `live` state the map already holds in the success tone, and a `test` state added beside it in the secondary tone. In the default state, when the filter hides nothing, neither the line nor the switch SHALL render.
#### Scenario: A billing view shows the key's environment
- **WHEN** an operator opens the invoices view under a live key and the projection holds live and test invoices
- **THEN** only the invoices whose mapping records live render, and the true total counts those rows
#### Scenario: An unverified row shows under either key
- **WHEN** a projected row's mapping holds `livemode` NULL
- **THEN** the row renders under a live key and under a test key alike
#### Scenario: The absence line names what is not shown and switches
- **WHEN** a billing view under a live key hides twelve invoices recorded in test
- **THEN** one muted line above the table reads `12 invoices from test mode are not shown. Show all`, and following the switch loads the same view with `env=all`
#### Scenario: The all state names itself and badges the other environment
- **WHEN** an operator views a billing list with `env=all` under a live key
- **THEN** the line reads `Showing all environments. Show live only`, and every row recorded in test carries the `Test` badge from the status-badge part
#### Scenario: Nothing hidden renders no line
- **WHEN** every row a billing view would render is in the key's environment, or unverified
- **THEN** neither the absence line nor the switch renders
#### Scenario: The switch survives search, the facet and paging
- **WHEN** an operator in the all state searches, filters by status, or moves to the second page
- **THEN** `env=all` is carried on the scaffold's URLs and the view stays in the all state
#### Scenario: A member's invoices are filtered without a switch
- **WHEN** a member opens their own invoices under a live key
- **THEN** only the invoices recorded live or unverified render, and no environment line, switch or badge renders
@@ -0,0 +1,341 @@
## MODIFIED Requirements
### Requirement: Operator product edit page surfaces purchasability readiness
The operator product edit page (`/operator/products/{id}`) SHALL display a
**purchasability readiness panel** that evaluates every precondition standing between
the product and a member being able to purchase it, and renders a single verdict —
**purchasable**, **ready to grant**, **inactive** or **incomplete** — with the specific unmet
preconditions named.
The panel SHALL evaluate and display the live state of each of these preconditions,
read from `billing.product_shape`:
- **Published**`lifecycle_status = 'published'`.
- **Active**`is_active = TRUE`, for **every** product. An inactive product is
offered by neither the member catalog nor the grant form, so the panel SHALL
report the row as unmet and the verdict SHALL read **Inactive** (never
**Purchasable** or **Ready to grant**) whenever the set and published
preconditions are met.
- **Listed**`is_public = TRUE`. For an unlisted product (`is_public = FALSE`;
Requirement: Wrap-product creation) the panel SHALL report the row as
"Unlisted; issued as grants" rather than **unmet** — unlisted products are never
meant to be in the catalog, and only the entitlement-set half of the gate applies
to them.
- **Entitlement set present, with rules**`product_shape.set_present`
(`entitlement_set_id IS NOT NULL`) and the set holds at least one active rule;
a present set with no active rule reads "No rules" and is unmet ("Incomplete;
missing: rules"). Required for **every** product regardless of
`is_public` — a published product with no entitlement set confers nothing,
whether it is a storefront product or an internal wrap product (Requirement:
Wrap-product creation).
- **Has an active price** — required **only for `is_public` products**:
`product_shape.billing_shape <> 'unpriced'` (at least one `billing.prices` row
with `is_active = TRUE`). Products with `is_public = FALSE` (wrap products) are
**exempt** from this precondition — they are minted priceless by design, and the
panel SHALL NOT report a missing price against them.
- **Payment processing** (the Stripe-mapped price precondition, labeled in
operator-facing, implementation-neutral terms) — evaluated only where a price is
required (`is_public` products); for `is_public = FALSE` products the panel SHALL
report this precondition as **not applicable** rather than unmet. Where it does
apply, the active price has a `stripe.price_mappings` entry, distinguishing
**synced**, **sync pending** (a sync was enqueued and the mapping has not landed
yet), and **sync failed** (the enqueued sync has terminally failed). The row's
detail SHALL name the environment the mapping records against the mode the
console derives from its API key, so the surface where syncing is triggered says
whether the id it holds can be reached:
- the mapping records no environment, or records the key's and has not been read
back: `Synced, unverified`
- the mapping records the key's environment and has been read back: `Synced, live`
/ `Synced, test`
- the mapping records the other environment: `Synced in test, key is live` /
`Synced in live, key is test`
- the mapping is `stale`, the environment check having asked Stripe for the id
under the current key and been answered `resource_missing`: `Not found in live
mode` / `Not found in test mode`, naming the key's environment
A mapping that records the other environment, and a stale mapping, SHALL be
reported as **unmet**, because no member checkout can reach the id, and the
verdict SHALL read "Incomplete; missing: payment processing". An unverified
mapping SHALL be reported as met, because it blocks nothing
(stripe-integration-infrastructure, Requirement: Mappings record the environment
that made their ids).
The panel SHALL additionally report, for `is_public` products, a **member catalog
visibility** line: whether the member catalog will actually render this product —
true when the product is a tier on a plan ladder or carries
`display_category = 'addon'` (the two sections the member catalog renders). This
line SHALL NOT change the purchasable verdict — off-ladder purchasability is by
design — but when the product is not rendered by the member catalog, the verdict
line SHALL carry an explicit qualifier (e.g. "Purchasable — not shown in the
member catalog"), stating in plain terms that members have no catalog path to it,
so **Purchasable** can never be read as **findable**. For `is_public = FALSE`
products the line reports **not applicable**.
The panel SHALL additionally report, for **diagnostics only and never as a
pass/fail precondition**, the product's shape from `billing.product_shape`:
`ladder_count`, `billing_shape`, and `consumption_shape`. These per-dimension
values are reported side by side and are never resolved into a single "kind"; a
product legitimately reporting, for example, `ladder_count = 0` alongside
`billing_shape = 'mixed'` is an ordinary, fully describable shape, not an
ambiguous or error state. A published product that is on no ladder and carries no
`display_category` is likewise an ordinary shape — off-ladder is not a limbo
state — but the panel SHALL say plainly, via the member-catalog-visibility line,
that the member catalog does not show it.
The readiness verdict SHALL be derived from the **same shared computation** the
member catalog uses to decide purchasability, so the operator panel and the member
catalog cannot disagree about whether a product is purchasable. This gate remains
**application-level**, not schema/CHECK-enforced.
"Stripe configured for the deployment" SHALL be determined by the deployment's Stripe
credentials being present (the Stripe API key and webhook secret are both set), not
merely by a Stripe querier being constructed. When Stripe is **not** configured, the
panel SHALL render an explanatory "Stripe is not configured" state for the
Stripe-mapped precondition rather than a bare "missing", and SHALL NOT offer the sync
action.
The Stripe-mapped precondition SHALL be **actionable**, not merely a marker:
- When Stripe is configured, the product has an active price, and that price is
neither mapped nor a sync is already pending, the panel SHALL offer an explicit
**"Sync to Stripe"** action that enqueues the Stripe product and price sync.
- When a previously-enqueued sync has **terminally failed** (dead-lettered), the panel
SHALL show the Stripe-mapped precondition as **sync failed** — surfacing the failure
(with the recorded error) rather than leaving it pinned at "sync pending" — and
SHALL offer a **Retry** action.
While a sync is **pending**, the panel SHALL live-update without a manual page
refresh: it polls its own readiness partial and stops polling once the state is
terminal (synced or failed), so the operator watches "sync pending" resolve in
place.
The panel MUST NOT change member-facing catalog behavior; it is an operator-side
legibility surface only, and the sync-failed state is derived without altering the
shared purchasability gate.
#### Scenario: A rule-less set is not ready
- **WHEN** an operator views the readiness panel of a published, active product whose entitlement set has no active rule
- **THEN** the Entitlement set row reads "No rules" and the verdict reads "Incomplete; missing: rules"
#### Scenario: Fully configured plan shows purchasable
- **WHEN** an operator views the edit page for a published, public, active plan
product that is a tier on a ladder, has an active price, and whose price is
Stripe-mapped
- **THEN** the readiness panel shows every precondition met and a verdict of
**Purchasable**
- **AND** the member-catalog-visibility line reports the product as shown in the
member catalog
#### Scenario: Plan missing a price shows incomplete with guidance
- **WHEN** an operator views a published, on-ladder plan product that has no active
`billing.prices` row
- **THEN** the readiness panel shows a verdict of **Incomplete**, names the missing
"active price" precondition, and offers inline guidance to add a price
#### Scenario: Unmapped price (Stripe configured) offers the Sync to Stripe action
- **WHEN** an operator views a product with an active price that has no Stripe
mapping, and Stripe is configured for the deployment
- **THEN** the readiness panel shows the Stripe-mapped precondition as unmet **and**
offers a "Sync to Stripe" action for it
#### Scenario: Price present but Stripe mapping pending
- **WHEN** an operator views a product that has an active price for which a Stripe
sync has been enqueued (a `stripe.price_mappings` row with `sync_status = 'pending'`)
and the enqueued outbox actions have not terminally failed, but no `stripe_price_id`
has landed yet
- **THEN** the readiness panel shows the Stripe-mapping precondition as **sync
pending** (not synced), does not offer the Sync action again, and the overall
verdict as **Incomplete**
#### Scenario: Pending sync resolves in place without a refresh
- **WHEN** an operator triggers Sync to Stripe and stays on the product page
- **THEN** the readiness panel SHALL poll while the sync is pending and update to
the terminal state (synced or failed) automatically, ceasing to poll thereafter
#### Scenario: Terminally-failed sync shows Sync failed with a Retry action
- **WHEN** an operator views a product whose enqueued Stripe sync has dead-lettered
(the `create_stripe_product` / `create_stripe_price` outbox entry reached
`dead_letter`) while the mapping is still unmapped
- **THEN** the readiness panel shows the Stripe-mapped precondition as **sync failed**
with the recorded error, and offers a **Retry** action rather than showing an
indefinite "sync pending"
#### Scenario: Stripe not configured shows an explanatory state, not a dead end
- **WHEN** an operator views a product with an active price and the deployment's
Stripe credentials are not set
- **THEN** the readiness panel shows the Stripe-mapped precondition with an
explanatory "Stripe not configured for this deployment" note and SHALL NOT offer
the "Sync to Stripe" action
#### Scenario: A mapping that records no environment reads unverified and is met
- **WHEN** an operator views a product whose active price carries a mapping written
before the environment stamp, or one written under the current key and not yet
read back
- **THEN** the Payment processing row reads `Synced, unverified`, is met, and the
verdict is unchanged by it
#### Scenario: A mapping from the other environment is unmet
- **WHEN** an operator views a product whose active price carries a mapping
recorded in test while the API key is live
- **THEN** the Payment processing row reads `Synced in test, key is live`, is
reported as unmet, and the verdict reads
"Incomplete; missing: payment processing"
#### Scenario: A stale mapping is unmet and names the key's environment
- **WHEN** an operator views a product whose active price carries a mapping the
environment check marked `stale` under a live key
- **THEN** the Payment processing row reads `Not found in live mode`, is reported as
unmet, and the verdict reads "Incomplete; missing: payment processing"
#### Scenario: Published, off-ladder, untyped product is an ordinary shape, not limbo
- **WHEN** an operator views a published, `is_public` product with `display_category`
blank, `ladder_count = 0`, an entitlement set, and an active, Stripe-mapped price
- **THEN** the readiness panel reports `ladder_count = 0` as diagnostic shape
information alongside `billing_shape` and `consumption_shape`, not as a violation
- **AND** the panel does NOT flag the product as being in a limbo or ambiguous state
- **AND**, having an entitlement set and an active mapped price, the verdict is
**Purchasable**
- **AND** the member-catalog-visibility line reports the product as not shown in the
member catalog, with the verdict carrying the "not shown in the member catalog"
qualifier
#### Scenario: Off-ladder public product with price and mapping is purchasable
- **WHEN** an operator views a published, public, active product that is on no
ladder, carries an entitlement set, and has an active, Stripe-mapped price
- **THEN** the readiness panel shows the entitlement-set and price preconditions met
(ladder membership is not required for purchasability) and a verdict of
**Purchasable**
- **AND** the verdict carries the "not shown in the member catalog" qualifier when
the product also lacks `display_category = 'addon'`
#### Scenario: Tier and addon products report as findable
- **WHEN** an operator views a published, public product that is a ladder tier, or
one carrying `display_category = 'addon'`
- **THEN** the member-catalog-visibility line reports the product as shown in the
member catalog
- **AND** no visibility qualifier is attached to the verdict
#### Scenario: Wrap product is exempt from the price precondition
- **WHEN** an operator views the edit page for an internal wrap product
(`is_public = FALSE`, `lifecycle_status = 'published'`, carrying an entitlement
set, and no `billing.prices` rows)
- **THEN** the readiness panel reports the "Entitlement set present" precondition
as met
- **AND** the panel SHALL NOT report the "Has an active price" or "Payment
processing" preconditions as unmet — they are reported as not applicable,
because `is_public = FALSE` products are exempt from the price half of the gate
#### Scenario: Wrap product's Visible precondition is not applicable, not unmet
- **WHEN** an operator views the edit page for an internal wrap product
(`is_public = FALSE`, `lifecycle_status = 'published'`, carrying an entitlement
set, and no `billing.prices` rows)
- **THEN** the readiness panel reports the **Visible** precondition as **not
applicable**
- **AND** the panel SHALL NOT report **Visible** as unmet or count it against the
product's readiness verdict — wrap products are never meant to be visible, and
only the entitlement-set half of the gate applies to them
### 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 Stripe API key and webhook secret are not both set).
- 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` so the outbox drains them again)
rather than treating the still-`pending` mapping as an in-flight no-op.
- 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** 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
@@ -0,0 +1,31 @@
## MODIFIED Requirements
### Requirement: Customer mappings link billing accounts to Stripe Customers
The system SHALL provide a `stripe.customer_mappings` table that maps each billing account to at most one Stripe Customer ID, with a `sync_status` tracking the mapping lifecycle. The row SHALL also record the Stripe environment its id was made in, `livemode BOOLEAN` nullable, under the vocabulary stripe-integration-infrastructure defines (Requirement: Mappings record the environment that made their ids); NULL is unverified and blocks nothing. Customer mappings are not read back by the environment check, so their flag is the only environment fact they carry. A mapping whose recorded environment disagrees with the key's mode is treated as absent by the checkout path, which creates the customer again in the current environment (stripe-subscription-creation, Requirement: Checkout endpoint creates a Stripe Checkout Session).
#### Scenario: Mapping is created when outbox activity succeeds
- **WHEN** the outbox executor successfully creates a Stripe Customer via POST /v1/customers
- **THEN** the system inserts a `stripe.customer_mappings` row with the `billing_account_id`, `stripe_customer_id`, `sync_status = 'synced'`, and `livemode` from the returned Stripe Customer
#### Scenario: A disagreeing mapping is treated as absent at checkout
- **WHEN** checkout reads a customer mapping recorded in test while the key is live
- **THEN** checkout creates a Stripe Customer again in the current environment rather than sending the unreachable id
#### Scenario: One-to-one constraint is enforced
- **WHEN** a second mapping is inserted for a `billing_account_id` that already has a mapping
- **THEN** the system rejects the insertion with a unique constraint violation
#### Scenario: Stripe Customer ID uniqueness is enforced
- **WHEN** a mapping is inserted with a `stripe_customer_id` that already exists in another mapping
- **THEN** the system rejects the insertion with a unique constraint violation
### Requirement: Outbox executor creates Stripe Customer and writes mapping
The system SHALL implement a `create_stripe_customer` outbox action executor that calls the Stripe API, creates the customer, and writes the mapping row, stamping the row with the `livemode` flag carried by the Stripe Customer the create call returned.
#### Scenario: Successful Stripe Customer creation
- **WHEN** the outbox executor processes a `create_stripe_customer` entry
- **THEN** the executor calls Stripe POST /v1/customers with org metadata, receives a `cus_xxx` ID, and inserts a `stripe.customer_mappings` row with `sync_status = 'synced'` and `livemode` copied from the returned customer, never derived from the API key
#### Scenario: Stripe API failure triggers retry
- **WHEN** the Stripe API returns an error during customer creation
- **THEN** the outbox entry follows the standard retry/backoff/dead-letter pattern without writing a mapping row
@@ -0,0 +1,204 @@
## ADDED Requirements
### Requirement: Mappings record the environment that made their ids
Each of the eight `stripe.*_mappings` tables (customers, products, prices, subscriptions, subscription items, invoices, payments and payment methods) SHALL carry `livemode BOOLEAN`, nullable, recording the Stripe environment the mapped id was created in. One migration SHALL add the column to all eight tables and write no value, so every existing row holds NULL and no row is guessed into an environment. `stripe.product_mappings` and `stripe.price_mappings` SHALL also carry `verified_at TIMESTAMPTZ`, nullable; the other six tables carry no verification column, because nothing reads them back and their flag is a fact recorded at write time.
The flag SHALL be written by whoever holds the object Stripe returned and SHALL NEVER be derived from the API key: `executeCreateStripeCustomer`, `executeCreateStripeProduct` and `executeCreateStripePrice` write it from the customer, product and price the create call returned; `upsertSubscription` in the fulfillment reconcile writes it from the refetched subscription, and `reconcileItems` writes that same subscription's flag onto the item mapping, because a Stripe subscription item carries no flag of its own; the invoice, payment and payment method projections write it from the object's own `livemode` inside the stored payload; and the product, price and customer webhook handlers write it from the object they parse when they upsert a mapping.
Four words name a mapping's state, each with one definition:
- **Unverified**: `livemode IS NULL`, or, on a product or price mapping, `verified_at IS NULL` while `livemode` is set and agrees with the key. An unverified mapping SHALL block nothing: checkout, the sync control, the reconcile, the plan switch and the scheduled-change sweep treat it as synced, and readiness names it `Synced, unverified`. Every row is unverified after the migration, and a product or price mapping returns to unverified when the API key changes, until the environment check runs.
- **Agrees**: `livemode` is set and equals the mode the console derives from the API key.
- **Disagrees**: `livemode` is set and differs from that mode, so the object cannot be reached under the current key.
- **Stale**: the environment check asked Stripe for the id under the current key and Stripe answered `resource_missing`. `sync_status = 'stale'` SHALL join `pending`, `synced` and `deleted` in the mapping status vocabulary, and the row's recorded `livemode` SHALL stay as it was, because the object still exists in the environment that made it. A stale mapping SHALL behave as a disagreeing one on every consumer, and it is the only way a move between two environments that both report `livemode` false is detected.
The Stripe store package SHALL expose one helper, `MappingAgrees(livemode sql.NullBool, keyMode string) bool`, returning true for NULL and for a match, so every consumer asks one question.
#### Scenario: The migration guesses nothing
- **WHEN** the migration adds `livemode` to the eight mapping tables on a deployment with existing rows
- **THEN** every existing row holds NULL, reads as unverified, and keeps working under the key it was created with
#### Scenario: A writer stamps the object it holds
- **WHEN** an outbox executor creates a Stripe product, price or customer under a test key
- **THEN** the mapping row records `livemode` false, taken from the object Stripe returned rather than from the key
#### Scenario: An item mapping records its parent's environment
- **WHEN** the reconcile writes a subscription item mapping
- **THEN** the row records the refetched subscription's `livemode`, because the Stripe subscription item object carries none
#### Scenario: Unverified blocks nothing
- **WHEN** a consumer reads a mapping whose `livemode` is NULL
- **THEN** `MappingAgrees` returns true and the consumer proceeds as it does today
#### Scenario: A disagreeing mapping is not synced
- **WHEN** a consumer reads a mapping recorded in test while the key is live
- **THEN** `MappingAgrees` returns false and the consumer takes its refusal or skip branch
#### Scenario: A stale mapping behaves as a disagreeing one
- **WHEN** a consumer reads a mapping whose `sync_status` is `stale`, whatever its recorded `livemode`
- **THEN** the consumer takes the same branch it takes for a disagreeing mapping
### Requirement: The environment check verifies ids under the current key
The console SHALL record which API key the last environment check ran under, and SHALL re-verify its product and price ids whenever the key changes, because an id created in one Stripe environment resolves in no other and two environments that both report `livemode` false cannot be told apart by the flag alone.
**The fingerprint and the record.** Boot SHALL compute a SHA-256 digest of the raw API key, the digest only and never the key. The record SHALL live in `core.instance_settings` under the key `stripe.environment_check`, a JSON object holding `key_fingerprint`, `started_at`, `finished_at`, `checked` and `stale`; the instance settings registry SHALL gain a JSON-object kind with a typed accessor beside its boolean one, and no migration, because the JSONB column and `updated_at` already carry the shape.
**Boot.** With Stripe configured, boot SHALL read the setting and compare the recorded fingerprint with the current key's. When they differ, or when no record exists, boot SHALL clear `verified_at` on every product and price mapping and start the check as a Temporal workflow, started and not awaited, so boot stays as fast as it is and a deployment whose Stripe is unreachable still comes up; a start that fails SHALL be logged and SHALL NOT fail boot. When the fingerprints match, boot SHALL do nothing.
**The workflow.** `StripeEnvironmentCheckWorkflow` SHALL run under the fixed workflow id `stripe-environment-check`, so a second start while one runs is deduplicated by Temporal rather than by the console. Its single activity SHALL list every product and price mapping carrying a Stripe id and SHALL issue `product.Get` or `price.Get` under the current key, one row at a time, heartbeating per row. Per row:
- the object resolves: the row takes the object's `livemode` and `verified_at = now()`, and a `stale` row that resolves again returns to `synced`;
- Stripe answers `resource_missing`: the row takes `sync_status = 'stale'` and `verified_at = now()`, and the activity logs `stripe: mapping id missing under the current key` with the table, the row id, the Stripe id and the mode;
- any other error: the activity fails and Temporal retries it under its policy; the rows already written stay written, and a rerun rewrites them the same way.
On completion the activity SHALL write the setting with the fingerprint, the times and the counts, and SHALL log `stripe: environment check complete` with `checked`, `stale`, `mode` and the first eight characters of the fingerprint. The start SHALL log `stripe: environment check started` with `mappings`, `mode`, the fingerprint prefix and `trigger=boot` or `trigger=operator`. Customer mappings SHALL NOT be read back: a customer id that dangles after a same-mode move surfaces at the member's next checkout, and a customer mapping that disagrees is caught without a read.
**The operator control.** The Stripe provider page SHALL carry a section `Environment check` under its readiness section, holding one resting line and one outline control, `Check now`, a declared action trigger posting to a route on the page that starts the same workflow with `trigger=operator`. The resting line reads, by state:
- no record yet and nothing running: `Not checked yet.`
- running: `Checking 36 Stripe ids under the current key.`
- finished under the current fingerprint: `Last checked Sep 18, 2026 3:04 PM. 34 verified, 2 stale.`, in the date format the page already uses
- finished under another fingerprint: `API key changed since the last check. 34 mappings unverified.`
The control's success toast reads `36 checked, 2 stale.` when the workflow finishes within the request's wait, and `Check started.` when it does not.
#### Scenario: A changed key starts the check at boot
- **WHEN** the console boots with an API key whose fingerprint differs from the recorded one, or with no record at all
- **THEN** boot clears `verified_at` on every product and price mapping, starts `StripeEnvironmentCheckWorkflow` without awaiting it, and logs the start with `trigger=boot`
#### Scenario: An unchanged key checks nothing
- **WHEN** the console boots with an API key whose fingerprint matches the recorded one
- **THEN** no workflow starts and no mapping is touched
#### Scenario: A resolving id is verified
- **WHEN** the activity fetches a mapped product or price and Stripe returns the object
- **THEN** the row takes the object's `livemode` and `verified_at = now()`, and a row that was `stale` returns to `synced`
#### Scenario: A missing id is marked stale
- **WHEN** Stripe answers `resource_missing` for a mapped id
- **THEN** the row takes `sync_status = 'stale'` and `verified_at = now()`, the recorded `livemode` is left as it was, and the activity logs `stripe: mapping id missing under the current key` with the table, the row id, the Stripe id and the mode
#### Scenario: Any other error is retried
- **WHEN** the fetch fails for a reason other than `resource_missing`
- **THEN** the activity fails and Temporal retries it, the rows already written stay written, and the rerun rewrites them the same way
#### Scenario: A second start is deduplicated
- **WHEN** a start is issued while the check is running
- **THEN** Temporal keeps the running execution under the workflow id `stripe-environment-check` and no second run begins
#### Scenario: The section states the outcome and re-runs the check
- **WHEN** an operator opens the Stripe provider page
- **THEN** the `Environment check` section reads `Not checked yet.`, `Checking 36 Stripe ids under the current key.`, `Last checked Sep 18, 2026 3:04 PM. 34 verified, 2 stale.` or `API key changed since the last check. 34 mappings unverified.` for its state
- **AND** the `Check now` control starts the workflow with `trigger=operator` and answers `36 checked, 2 stale.` when the run finishes within the request's wait, `Check started.` when it does not
#### Scenario: Boot without Temporal still comes up
- **WHEN** the workflow start fails at boot
- **THEN** boot logs the failure and continues, and the page's control starts the check later
## MODIFIED Requirements
### Requirement: Webhook events are captured idempotently
The system SHALL provide an `integration.webhook_events` table that stores inbound webhook events with idempotent deduplication per provider and provider event identifier, and SHALL record the provider's own event time alongside the time of receipt.
The table SHALL carry `provider_environment TEXT`, nullable and without a check constraint, holding the provider's own word for the environment the event came from, as `provider`, `provider_event_id`, `event_type` and `provider_event_at` hold each provider's own values in neutral columns. The Stripe handler SHALL write `live` or `test` from the event envelope's `Livemode`, which it discards today.
An event whose environment differs from the mode the console derives from its API key SHALL be stored with `status = 'refused'`, no workflow SHALL start for it, and the endpoint SHALL answer 200 so the provider stops redelivering. `refused` SHALL stay outside the unfinished statuses, so neither a redelivery nor the boot sweep starts a workflow for a refused row. The Stripe provider page's Inbound events section SHALL render one line when the refused count is above zero, `3 events arrived in test mode under a live key.`, and a refused row in that page's table SHALL carry the detail `Refused, mode mismatch`. A refused event SHALL log `stripe: webhook event mode disagrees with the key; captured, not processed` with `event_id`, `event_type`, `event_mode` and `key_mode`.
#### Scenario: Duplicate webhook delivery is deduplicated
- **WHEN** Stripe delivers an event with a provider event identifier already present in `integration.webhook_events`
- **THEN** the insertion is ignored and the HTTP handler returns success without creating a duplicate record
#### Scenario: Webhook event is stored with processing state
- **WHEN** a new webhook event is received from Stripe
- **THEN** the system stores the event type, scrubbed payload, provider identifier, the event's own `created` time, and initial status `received`
#### Scenario: A Stripe event records the environment it came from
- **WHEN** a Stripe event with `livemode` false arrives under a test key
- **THEN** the row's `provider_environment` reads `test`, the status reads `received`, and the event is processed as before
#### Scenario: An event from the other environment is captured and refused
- **WHEN** a Stripe event with `livemode` false arrives under a live key
- **THEN** the row reads `provider_environment = 'test'` and `status = 'refused'`, no workflow starts, the endpoint answers 200, and the handler logs `stripe: webhook event mode disagrees with the key; captured, not processed` with the event id, the event type, `event_mode` and `key_mode`
#### Scenario: A refused event is not restarted
- **WHEN** Stripe redelivers an event already stored `refused`, or the system boots with refused rows present
- **THEN** no workflow starts, no second row is written, and the redelivery is answered 200
#### Scenario: The provider page counts the refused events
- **WHEN** an operator opens the Stripe provider page after three events arrived in test mode under a live key
- **THEN** the Inbound events section renders `3 events arrived in test mode under a live key.` and each refused row carries the detail `Refused, mode mismatch`
- **AND** when no event has been refused, that line does not render
### Requirement: Stripe webhook endpoint ingests events asynchronously
The system SHALL expose a `/webhooks/stripe` HTTP endpoint that verifies Stripe webhook signatures, stores the event, starts the event's workflow, and returns a success response without processing the event in the request path. The endpoint SHALL acknowledge with 2xx only when the event has been durably recorded in `core.webhook_events` and its workflow started, when the event is a duplicate of one already finished, or when the event is recorded `refused` because its environment disagrees with the key's mode (Requirement: Webhook events are captured idempotently), which records the row and starts nothing; when the insert fails, or the workflow cannot be started, the endpoint SHALL return HTTP 500 so Stripe redelivers the event. A redelivery of an unfinished event SHALL start its workflow again, which is a no-op when the execution is running; a redelivery of a refused event starts nothing, because `refused` is not an unfinished status.
#### Scenario: Valid Stripe webhook is accepted
- **WHEN** Stripe POSTs a webhook event with a valid signature to `/webhooks/stripe`
- **THEN** the handler persists the event to `core.webhook_events` with status `received`, starts its workflow, and returns HTTP 200
#### Scenario: Insert failure answers 5xx so Stripe redelivers
- **WHEN** the `core.webhook_events` insert fails (for example, a transient database error)
- **THEN** the handler logs the failure and returns HTTP 500 without acknowledging the event
- **AND** no event is silently dropped: Stripe's redelivery of the same event later succeeds and is recorded once
#### Scenario: Workflow start failure answers 5xx and keeps the row
- **WHEN** the event is recorded but its workflow cannot be started
- **THEN** the handler returns HTTP 500 with the row kept, and Stripe's redelivery starts the workflow
#### Scenario: Duplicate delivery is acknowledged without a second record
- **WHEN** Stripe redelivers an event whose `(provider, provider_event_id)` is already recorded
- **THEN** the handler returns HTTP 200 and no second row is written; if the row is unfinished the workflow start is issued again, and if it is finished nothing starts
#### Scenario: Invalid Stripe signature is rejected
- **WHEN** a request to `/webhooks/stripe` has an invalid or missing signature
- **THEN** the handler returns HTTP 400 without persisting the event
#### Scenario: Webhook endpoint bypasses CSRF protection
- **WHEN** a request is made to `/webhooks/stripe`
- **THEN** the handler processes the request without requiring a CSRF token
#### Scenario: A refused event is acknowledged without a workflow
- **WHEN** a signed Stripe event arrives whose environment disagrees with the key's mode
- **THEN** the handler records the row `refused`, starts no workflow, and answers HTTP 200
### Requirement: Stripe mode is derived from the API key
The console SHALL derive its Stripe mode from the configured API key's prefix at boot: `sk_live_` or `rk_live_` is live, `sk_test_` or `rk_test_` is test, an empty key is unset, and any other prefix SHALL fail boot with a message naming the accepted prefixes. No setting SHALL declare the mode; the `stripe-mode` configuration key is retired and a stored override for it is removed by migration. Every surface that shows or acts on the mode (the test-mode banner, the mode label on the overview and the Stripe provider page, the readiness panel's Payment processing detail, dashboard links) SHALL read the derived value; the readiness detail reads it as the key's half of a comparison against the environment the mapping records (Requirement: Mappings record the environment that made their ids), never as the environment the mapped object lives in. Dashboard links SHALL carry the mode and no account: the console SHALL NOT read the account from the API (a restricted key would need Stripe's Connect "Accounts Read" permission) nor accept a declared account id (nothing could check it against the key).
Beside the derived mode, boot SHALL compute a SHA-256 fingerprint of the raw API key, the digest only and never the key, which the environment check records as the key it ran under (Requirement: The environment check verifies ids under the current key).
#### Scenario: A live key is live
- **WHEN** the console boots with an API key beginning `sk_live_`
- **THEN** the mode is live, no test banner renders, and the mode label reads "Live mode"
#### Scenario: A sandbox key is test
- **WHEN** the console boots with an API key beginning `sk_test_`
- **THEN** the mode is test and the test banner renders on billing views
#### Scenario: A malformed key refuses boot
- **WHEN** the console boots with an API key whose prefix is none of the accepted four
- **THEN** boot fails and the message names the accepted prefixes
#### Scenario: The key's fingerprint is derived beside its mode
- **WHEN** the console boots with a configured API key
- **THEN** it holds the mode and the key's SHA-256 digest, and no surface and no log line carries the key itself
@@ -0,0 +1,130 @@
## MODIFIED Requirements
### Requirement: Product mappings link core products to Stripe Products
The system SHALL provide a `stripe.product_mappings` table that maps each billing product to at most one Stripe Product ID, with a `sync_status` tracking the mapping lifecycle (`pending`, `synced`, `deleted`, `stale`). The row SHALL also record the Stripe environment its id was made in, `livemode BOOLEAN` nullable, and when it was last read back under the current key, `verified_at TIMESTAMPTZ` nullable; the two columns and the `stale` status carry the vocabulary defined by stripe-integration-infrastructure (Requirement: Mappings record the environment that made their ids), where NULL is unverified and blocks nothing.
#### Scenario: Mapping is created when outbox executor succeeds
- **WHEN** the outbox executor successfully creates a Stripe Product via POST /v1/products
- **THEN** the system inserts a `stripe.product_mappings` row with `product_id`, `stripe_product_id`, `sync_status = 'synced'`, and `livemode` from the returned Stripe Product
#### Scenario: A mapping the check cannot resolve is stale
- **WHEN** the environment check fetches a mapped product id under the current key and Stripe answers `resource_missing`
- **THEN** the row reads `sync_status = 'stale'` with `verified_at` set, and its recorded `livemode` is unchanged
#### Scenario: One-to-one constraint is enforced
- **WHEN** a second mapping is inserted for a `product_id` that already has a mapping
- **THEN** the system rejects the insertion with a unique constraint violation
#### Scenario: Stripe Product ID uniqueness is enforced
- **WHEN** a mapping is inserted with a `stripe_product_id` that already exists in another mapping
- **THEN** the system rejects the insertion with a unique constraint violation
### Requirement: Price mappings link core prices to Stripe Prices
The system SHALL provide a `stripe.price_mappings` table that maps each billing price to at most one Stripe Price ID, with a `sync_status` tracking the mapping lifecycle (`pending`, `synced`, `deleted`, `stale`). The row SHALL also record the Stripe environment its id was made in, `livemode BOOLEAN` nullable, and when it was last read back under the current key, `verified_at TIMESTAMPTZ` nullable; the two columns and the `stale` status carry the vocabulary defined by stripe-integration-infrastructure (Requirement: Mappings record the environment that made their ids), where NULL is unverified and blocks nothing.
#### Scenario: Mapping is created when outbox executor succeeds
- **WHEN** the outbox executor successfully creates a Stripe Price via POST /v1/prices
- **THEN** the system inserts a `stripe.price_mappings` row with `price_id`, `stripe_price_id`, `sync_status = 'synced'`, and `livemode` from the returned Stripe Price
#### Scenario: A mapping the check cannot resolve is stale
- **WHEN** the environment check fetches a mapped price id under the current key and Stripe answers `resource_missing`
- **THEN** the row reads `sync_status = 'stale'` with `verified_at` set, and its recorded `livemode` is unchanged
#### Scenario: One-to-one constraint is enforced
- **WHEN** a second mapping is inserted for a `price_id` that already has a mapping
- **THEN** the system rejects the insertion with a unique constraint violation
#### Scenario: Stripe Price ID uniqueness is enforced
- **WHEN** a mapping is inserted with a `stripe_price_id` that already exists in another mapping
- **THEN** the system rejects the insertion with a unique constraint violation
### 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.
#### 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
- **THEN** the outbox entry follows the standard retry/backoff/dead-letter pattern without writing a mapping row
### 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.
#### 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
- **THEN** the outbox entry follows the standard retry/backoff/dead-letter pattern without writing a mapping row
### Requirement: Webhook processor handles product lifecycle events
The system SHALL process `product.created`, `product.updated`, and `product.deleted` webhook events to keep `stripe.product_mappings` consistent with Stripe-side state. Where a handler upserts a mapping row, it SHALL write `livemode` from the product object it parses.
#### Scenario: product.created webhook confirms mapping
- **WHEN** a `product.created` webhook is processed and a mapping already exists for that `stripe_product_id`
- **THEN** the handler updates `updated_at` on the existing mapping and marks the webhook event `completed`
#### Scenario: product.created webhook arrives before outbox (race)
- **WHEN** a `product.created` webhook is processed and no mapping exists for the `stripe_product_id`
- **THEN** the handler upserts a mapping row with `sync_status = 'synced'` and `livemode` from the parsed product object, and marks the webhook event `completed`
#### Scenario: product.updated webhook is acknowledged
- **WHEN** a `product.updated` webhook is processed
- **THEN** the handler updates `updated_at` on the mapping and marks the webhook event `completed`
#### Scenario: product.deleted webhook marks mapping deleted
- **WHEN** a `product.deleted` webhook is processed
- **THEN** the handler updates the corresponding mapping's `sync_status` to `deleted` and marks the webhook event `completed`
### Requirement: Webhook processor handles price lifecycle events
The system SHALL process `price.created` and `price.updated` webhook events to keep `stripe.price_mappings` consistent with Stripe-side state. Where a handler upserts a mapping row, it SHALL write `livemode` from the price object it parses. Stripe archives prices rather than deleting them; a `price.updated` event with `active=false` is the deactivation signal.
#### Scenario: price.created webhook confirms mapping
- **WHEN** a `price.created` webhook is processed and a mapping already exists for that `stripe_price_id`
- **THEN** the handler updates `updated_at` on the existing mapping and marks the webhook event `completed`
#### Scenario: price.created webhook arrives before outbox (race)
- **WHEN** a `price.created` webhook is processed and no mapping exists for the `stripe_price_id`
- **THEN** the handler upserts a mapping row with `sync_status = 'synced'` and `livemode` from the parsed price object, and marks the webhook event `completed`
#### Scenario: price.updated with active=false marks mapping deleted
- **WHEN** a `price.updated` webhook is processed and the payload contains `active=false`
- **THEN** the handler updates the corresponding mapping's `sync_status` to `deleted` and marks the webhook event `completed`
#### Scenario: price.updated with active=true is acknowledged
- **WHEN** a `price.updated` webhook is processed and the payload contains `active=true`
- **THEN** the handler updates `updated_at` on the mapping and marks the webhook event `completed`
@@ -0,0 +1,124 @@
## MODIFIED Requirements
### Requirement: Checkout endpoint creates a Stripe Checkout Session
The system SHALL expose an authenticated endpoint that creates a Stripe Checkout Session in `subscription` mode and redirects the member to Stripe's hosted payment page. Before creating a session, the system SHALL load the product behind the requested price and validate it through the shared member-purchasability gate: the product MUST have `lifecycle_status = 'published'`, `is_active = TRUE`, and `is_public = TRUE`. A request failing the gate SHALL be rejected before any Stripe interaction, so an unpublished product can never be paid for and rejected only at post-payment conferral. The session SHALL reference the member's Stripe Customer ID and the requested Stripe Price ID. The session's `metadata` SHALL include `billing_account_id` for webhook correlation.
The system SHALL also check, before any Stripe call, that the requested price's mapping can be reached under the current API key: a mapping whose recorded environment disagrees with the key's mode, or whose `sync_status` is `stale`, SHALL be refused with the same message an unmapped price is refused with, `price not yet available in Stripe`, because the id is unreachable for the same reason the member cares about. A customer mapping that disagrees with the key's mode SHALL be treated as absent, so the system creates the Stripe Customer again in the current environment and writes the mapping with the new id rather than sending an id the key cannot reach. An unverified mapping (`livemode IS NULL`) SHALL be treated as reachable, per stripe-integration-infrastructure (Requirement: Mappings record the environment that made their ids).
#### Scenario: Successful checkout initiation
- **WHEN** an authenticated member with an active billing account and a synced Stripe Customer mapping requests checkout for an active price of a published, active, public product with a synced Stripe Price mapping
- **THEN** the system creates a Stripe Checkout Session with `mode = 'subscription'`, `customer` set to the mapped `stripe_customer_id`, and `line_items` containing the mapped `stripe_price_id`
- **AND** the system redirects the member (HTTP 303) to the Checkout Session URL
#### Scenario: Checkout rejects unpublished product
- **WHEN** the requested price belongs to a product whose `lifecycle_status` is `draft` or `retired`, or whose `is_active` or `is_public` is `FALSE`
- **THEN** the system rejects the request with an error and does not create a Checkout Session
- **AND** no Stripe API call is made for the rejected request
#### Scenario: Checkout creates Stripe Customer synchronously if missing
- **WHEN** the member's billing account has no synced Stripe Customer mapping
- **THEN** the system creates a Stripe Customer via the API with org metadata, writes a `customer_mappings` row with `sync_status = 'synced'`, and proceeds with Checkout Session creation
#### Scenario: Checkout re-creates a customer the key cannot reach
- **WHEN** the member's billing account has a synced Stripe Customer mapping recorded in the environment the key is not in
- **THEN** the system treats the mapping as absent, creates the customer again in the current environment with its `livemode`, and uses the new `stripe_customer_id` for the Checkout Session
#### Scenario: Checkout rejects inactive price
- **WHEN** the requested price has `is_active = false`
- **THEN** the system rejects the request with an error and does not create a Checkout Session
#### Scenario: Checkout rejects price without Stripe mapping
- **WHEN** the requested price has no synced `stripe.price_mappings` row
- **THEN** the system rejects the request with an error indicating the price is not yet available in Stripe
#### Scenario: Checkout refuses a price the key cannot reach
- **WHEN** the requested price has a `stripe.price_mappings` row recorded in test while the key is live, or the reverse, or a row whose `sync_status` is `stale`
- **THEN** the system refuses the request with `price not yet available in Stripe` before any Stripe API call, and no Checkout Session is created
#### Scenario: Checkout proceeds on an unverified price mapping
- **WHEN** the requested price's mapping holds `livemode` NULL
- **THEN** checkout proceeds as it does today, because an unverified mapping is not a disagreement
#### Scenario: Checkout metadata carries billing account ID
- **WHEN** a Checkout Session is created
- **THEN** the session's `metadata` SHALL include `billing_account_id` set to the member's billing account UUID
- **AND** the session's `success_url` and `cancel_url` SHALL point back to the application
### Requirement: Subscription mappings link core subscriptions to Stripe
The system SHALL maintain `stripe.subscription_mappings` and `stripe.subscription_item_mappings` tables following the same pattern as customer/product/price mappings. Both tables SHALL record the Stripe environment their ids were made in, `livemode BOOLEAN` nullable, under the vocabulary stripe-integration-infrastructure defines (Requirement: Mappings record the environment that made their ids). The item mapping SHALL record its parent subscription's flag, because the Stripe subscription item object carries no environment of its own, so the two tables cannot disagree.
#### Scenario: Subscription mapping structure
- **WHEN** a subscription mapping is created
- **THEN** the `stripe.subscription_mappings` record SHALL have `mapping_id` (UUID PK), `subscription_id` (FK to `billing.subscriptions`, UNIQUE), `stripe_subscription_id` (TEXT, UNIQUE), `sync_status` (one of `pending`, `synced`, `deleted`, `stale`), `livemode` (BOOLEAN, nullable), and standard audit timestamps
#### Scenario: Subscription item mapping structure
- **WHEN** a subscription item mapping is created
- **THEN** the `stripe.subscription_item_mappings` record SHALL have `mapping_id` (UUID PK), `subscription_item_id` (FK to `billing.subscription_items`, UNIQUE), `stripe_subscription_item_id` (TEXT, UNIQUE), `sync_status`, `livemode` (BOOLEAN, nullable, holding the parent subscription's flag), and standard audit timestamps
#### Scenario: One-to-one constraint is enforced
- **WHEN** a mapping is inserted with a `subscription_id` that already has a mapping
- **THEN** the system rejects the insertion with a unique constraint violation
### Requirement: Subscription fulfillment reconciles from the Stripe API
The system SHALL fulfill and maintain subscription state through a single idempotent **reconcile** operation keyed by a Stripe subscription id. Reconcile SHALL fetch the current subscription — including its items and their prices — from the Stripe API and converge core records (`billing.subscriptions`, `billing.subscription_items`, `stripe.subscription_mappings`, `stripe.subscription_item_mappings`) and pool entitlements to match the fetched state. Reconcile SHALL NOT derive items, status, or period boundaries from the webhook event payload. Reconcile SHALL be safe to run repeatedly and in any order, producing the same end state (convergent idempotency). Pool-provision creation, ladder attachment, and ending SHALL occur only via the `plan-transitions.Transition` primitive with `actor_type = 'webhook'` and `actor_id = NULL`.
Reconcile SHALL write the refetched subscription's `livemode` onto both `stripe.subscription_mappings` and `stripe.subscription_item_mappings`, the item mapping taking its parent's flag. Before it calls Stripe, reconcile SHALL read the stored subscription mapping: one whose recorded environment disagrees with the key's mode, or whose `sync_status` is `stale`, SHALL be skipped rather than fetched, and the skip SHALL log `reconcile: subscription mapping unreachable under the current key; skipping` with the row id, the Stripe id, `recorded_mode` and `key_mode`, naming `stale` in place of the recorded mode for a stale row. An unverified mapping SHALL be reconciled as it is today.
#### Scenario: Reconcile fetches item state from the API, not the payload
- **WHEN** reconcile runs for a subscription whose triggering event payload carried no `line_items`
- **THEN** the system fetches the subscription's items from the Stripe API and creates one `billing.subscription_items` row (and pool entitlement) per fetched item
- **AND** an empty `checkout.session.completed` payload still yields full entitlement
#### Scenario: Reconcile converges on first run
- **WHEN** reconcile runs for a subscription with no existing core records
- **THEN** the system inserts `billing.subscriptions`, `billing.subscription_items`, `stripe.subscription_mappings`, and `stripe.subscription_item_mappings`
- **AND** routes each plan item through `plan-transitions.Transition` to create the pool provision and ladder attachment
- **AND** appends a `billing.subscription_changes` row with `previous_status = NULL` and `new_status` set from the fetched status
#### Scenario: Reconcile is idempotent on replay
- **WHEN** reconcile runs again for an already-fulfilled subscription whose Stripe state is unchanged
- **THEN** the system makes no entitlement changes, creates no duplicate provision or ladder attachment, and appends no `subscription_changes` row
- **AND** it does so by converging to the same state, not by bailing on a pre-existing mapping
#### Scenario: Reconcile converges item changes
- **WHEN** the fetched subscription's item set differs from the stored set (item added, removed, or quantity changed)
- **THEN** the system upserts or ends `billing.subscription_items` and adjusts pool provisions so the stored set matches the fetched set
#### Scenario: Reconcile reads period boundaries per the pinned API version
- **WHEN** reconcile reads `current_period_start` and `current_period_end`
- **THEN** the system reads them from the location dictated by the SDK-pinned Stripe API version's response shape, not from the differently-versioned webhook payload
#### Scenario: Eager and webhook reconcile of the same subscription are consistent
- **WHEN** the checkout-return eager path and a webhook both reconcile the same subscription id
- **THEN** the resulting state is identical and no duplicate provision or ladder attachment is created
#### Scenario: Reconcile stamps both mapping tables
- **WHEN** reconcile refetches a subscription created in test
- **THEN** the subscription mapping and every item mapping it writes record `livemode` false, the items taking the parent subscription's flag
#### Scenario: Reconcile skips a subscription the key cannot reach
- **WHEN** reconcile runs for a subscription whose mapping is recorded in the environment the key is not in, or whose `sync_status` is `stale`
- **THEN** reconcile makes no Stripe call and changes nothing, and logs `reconcile: subscription mapping unreachable under the current key; skipping` with the row id, the Stripe id, `recorded_mode` and `key_mode`
@@ -0,0 +1,64 @@
## MODIFIED Requirements
### Requirement: Member-initiated paid-to-paid plan switch
The system SHALL provide a member-initiated endpoint that switches an active paid subscription from its current tier to another paid tier on the same ladder by **modifying the existing subscription item's price** to the target tier's `price_id` with Stripe proration (`proration_behavior=create_prorations`). It SHALL NOT create a second subscription, nor cancel-and-recreate. The switch SHALL take effect immediately. After the modification, `fulfillment.ReconcileSubscription`'s product-keyed diff (see "Subscription reconcile computes a genuine diff, grouped by product") SHALL converge the item change: the prior tier's product becomes surplus and SHALL be ended via `entitlements.end_conferral`, and the target tier's product becomes missing and SHALL be conferred via `entitlements.confer`, sourced by the subscription — so that exactly one tier's position is held on that ladder. The recorded position transitions and `subscription_changes` SHALL attribute the member as initiator.
The target tier's product SHALL clear the member gate that checkout applies (published, active, public) before any subscription is read. A price can outlive its product's publication state, so the gate is checked on the product, independently of the listing queries that hide unpublished tiers from the catalog.
The switch SHALL also refuse, before any Stripe call, when a Stripe id it would send cannot be reached under the current API key: the target price mapping, the subscription mapping or the item mapping records an environment that disagrees with the key's mode, or carries `sync_status = 'stale'` (stripe-integration-infrastructure, Requirement: Mappings record the environment that made their ids). The refusal SHALL carry the unavailable-plan message the unpublished-tier refusal carries, SHALL change nothing, and SHALL log `plan_change: price mapping unreachable under the current key; skipping` with the row id, the Stripe id, `recorded_mode` and `key_mode`, naming `stale` in place of the recorded mode for a stale row. An unverified mapping is not a disagreement and SHALL switch as it does today.
#### Scenario: Switch modifies the existing subscription in place
- **WHEN** a member on a paid tier of a ladder switches to another paid tier on the same ladder
- **THEN** the system SHALL modify the existing subscription's item price to the target tier's `price_id`
- **AND** it SHALL NOT create a new subscription or cancel the existing one
#### Scenario: Switch prorates and supersedes the ladder attachment
- **WHEN** a paid-to-paid switch is applied mid-cycle
- **THEN** Stripe SHALL emit `proration_credit`/`proration_charge` line items for the unused and new portions
- **AND** reconcile's diff SHALL end the prior tier's conferral via `entitlements.end_conferral` and confer the target tier via `entitlements.confer`, so exactly one tier's position is held on that ladder
#### Scenario: Switch is rejected when there is no active paid subscription on the ladder
- **WHEN** a member requests a paid-to-paid switch but holds no active paid subscription on the target ladder
- **THEN** the system SHALL reject the request rather than create a subscription (the free/default→paid path is owned by `member-plan-upgrade` / Checkout)
#### Scenario: Switch onto an unpublished tier is refused
- **WHEN** a member posts a switch whose target price belongs to a product that is draft, retired, inactive or not public, even one with an active, Stripe-mapped price
- **THEN** the request SHALL be refused with the unavailable-plan message before any subscription is read, and nothing SHALL change
#### Scenario: Switch onto a price the key cannot reach is refused
- **WHEN** a member posts a switch whose target price mapping, subscription mapping or item mapping is recorded in the environment the key is not in, or carries `sync_status = 'stale'`
- **THEN** the request SHALL be refused with the unavailable-plan message before any Stripe call, nothing SHALL change, and the system SHALL log `plan_change: price mapping unreachable under the current key; skipping` with the row id, the Stripe id, `recorded_mode` and `key_mode`
### Requirement: Scheduled change fires idempotently and produces a recorded change
When a scheduled cancellation's `effective_trigger` condition is met, the system SHALL fire it via the Stripe subscription-schedule / `customer.subscription.deleted` webhook (primary) or a periodic **sweeper** that claims `status='scheduled'` rows whose `effective_at` has passed (backstop). Firing SHALL **produce** the `subscription_changes` record, end the provision, and invoke `entitlements.ReapplyDefaultsForPool`. Firing SHALL be **idempotent**: the row's `status` SHALL transition `scheduled→applied` within the same transaction that writes the `subscription_changes` record, so the webhook and the sweeper cannot double-apply. `effective_at` on the record tables SHALL equal the firing instant, never a future value.
Before it claims a due row, the sweeper SHALL read the Stripe ids the firing would send: the subscription mapping, and for a plan switch the target price mapping and the item mapping. When one of them records an environment that disagrees with the key's mode, or carries `sync_status = 'stale'` (stripe-integration-infrastructure, Requirement: Mappings record the environment that made their ids), the sweeper SHALL skip the row without claiming it, leaving its `status` at `scheduled` so a later run fires it once the ids are reachable, and SHALL log `sweep: price mapping unreachable under the current key; skipping scheduled change` with the row id, the Stripe id, `recorded_mode` and `key_mode`, naming `stale` in place of the recorded mode for a stale row. An unverified mapping SHALL fire as it does today.
#### Scenario: Webhook fires the scheduled cancellation
- **WHEN** the Stripe schedule/subscription-deleted webhook arrives for a scheduled cancellation
- **THEN** the system SHALL produce the `subscription_changes` record, end the provision, and run `ReapplyDefaultsForPool`
- **AND** it SHALL mark the scheduled-change row `applied`
#### Scenario: Sweeper backstops a missed webhook
- **WHEN** a scheduled cancellation's `effective_at` has passed but no webhook has fired it
- **THEN** the sweeper SHALL claim and fire the row, producing the same record and transition
#### Scenario: Double-firing is prevented
- **WHEN** both the webhook and the sweeper attempt to fire the same scheduled change
- **THEN** only the first SHALL apply it (status guard `scheduled→applied` in-transaction)
- **AND** the second SHALL no-op
#### Scenario: A change the key cannot fire is skipped, not consumed
- **WHEN** a due scheduled change's subscription, price or item mapping is recorded in the environment the key is not in, or carries `sync_status = 'stale'`
- **THEN** the sweeper SHALL make no Stripe call, SHALL leave the row `scheduled` rather than claiming it, and SHALL log `sweep: price mapping unreachable under the current key; skipping scheduled change` with the row id, the Stripe id, `recorded_mode` and `key_mode`
@@ -0,0 +1,364 @@
## 1. Schema, sqlc and the settings registry (design D1, D3)
- [x] 1.1 A new stripe store migration,
`00004_mapping_environment.sql` in
`internal/integrations/stripe/store/migrations/` (00003 is the
latest): add `livemode BOOLEAN` (nullable) to all eight
`stripe.*_mappings` tables and write no data, so every existing
row reads unverified and no row is guessed into a world
(design D1).
- [x] 1.2 The same migration adds `verified_at TIMESTAMPTZ` (nullable) to
`product_mappings` and `price_mappings` only, the two tables the
check in design D3 reads back; the other six carry no verification
column.
- [x] 1.3 `stale` needs no DDL: `sync_status` is a bare `TEXT NOT NULL
DEFAULT` on all eight tables in `00001_init.sql` (lines 57 to 113)
with no CHECK. Say so in the migration's comment and add `stale`
where the vocabulary is enumerated in Go instead:
`knownStates` in `internal/server/anatomy.go` and the
`not_mapped`/`synced` branches in `internal/server/operator_billing.go`.
- [x] 1.4 A new core migration, `00020_webhook_event_environment.sql` in
`internal/db/migrations/` (00019 is the latest):
`provider_environment TEXT` (nullable, no CHECK) on
`core.webhook_events`, following `00016_webhook_event_time.sql` as
the precedent for a new events column (design D6: the provider's
own vocabulary on a provider-neutral table).
- [x] 1.5 `internal/integrations/stripe/store/queries/`: the eight mapping
writers take `livemode`, one per file: `UpsertCustomerMapping`,
`UpsertProductMapping`, `UpsertPriceMapping`,
`UpsertSubscriptionMapping`, `UpsertSubscriptionItemMapping`,
`InsertInvoiceMapping`, `InsertPaymentMapping`,
`UpsertPaymentMethodMapping`.
- [x] 1.6 New queries in `product_mappings.sql` and `price_mappings.sql`
for the check: list every row holding a Stripe id; record a
resolved row's `livemode` and `verified_at`; mark a row `stale`
with `verified_at`; clear `verified_at` on the whole table (the
boot step in design D3).
- [x] 1.7 `make sqlc-generate` for the stripe store and the core stores
(`internal/integrations/stripe/store/sqlc.yaml`,
`internal/instance/sqlc.yaml`, `internal/billing/sqlc.yaml`);
`models.go` and `querier.go` regenerate with the new columns.
- [x] 1.8 `internal/instance/settings.go`: a second `settingKind` beside
`kindBool` for a JSON object, a typed accessor pair beside
`GetBool`/`SetBool`, and the key `stripe.environment_check` in the
`registry` map (design D3: the JSONB column and `updated_at`
already fit, so no migration).
## 2. The writers stamp the mapping (design D1, D4)
- [x] 2.1 `internal/integrations/stripe/workflows/outbox.go`,
`executeCreateStripeCustomer`: write the returned `*stripego.Customer`'s
`Livemode` onto the customer mapping.
- [x] 2.2 Same file, `executeCreateStripeProduct`: write the returned
`*stripego.Product`'s `Livemode` onto the product mapping.
- [x] 2.3 Same file, `executeCreateStripePrice`: write the returned
`*stripego.Price`'s `Livemode` onto the price mapping.
- [x] 2.4 `internal/fulfillment/reconcile.go`, `upsertSubscription`: write
the refetched subscription's `Livemode` onto the subscription
mapping (design D4: the console never creates a subscription, so
the stamp rides the read-back reconcile already performs).
- [x] 2.5 Same file, `reconcileItems`: write the parent subscription's
flag onto every item mapping, because `SubscriptionItem` carries
no `Livemode` of its own (design D1, D4).
- [x] 2.6 The two projection structs gain the field and their writers pass
it: `webhookInvoicePayload` in `webhook_invoice.go` (feeding
`InsertInvoiceMapping` and `InsertPaymentMapping` from
`handleInvoiceFinalized`, `handleInvoicePaid`,
`handleInvoicePaymentFailed` and `handleInvoiceVoided`) and
`webhookPaymentMethodPayload` in `webhook_payment_method.go`
(feeding `handlePaymentMethodAttached` and
`handlePaymentMethodUpdated`). The flag comes from the object's own
`livemode` inside the stored payload, not from the envelope.
- [x] 2.7 `internal/integrations/stripe/workflows/webhook.go`: the same
field on `webhookCustomerPayload`, `webhookProductPayload` and
`webhookPricePayload`, written by `handleCustomerEvent`,
`handleProductEvent` and `handlePriceEvent` when a `*.updated`
event updates a mapping.
- [x] 2.8 Re-pin the writers' tests with `livemode` on every canned object,
the Risks section's guard against a wrong stamp:
`outbox_activity_test.go`, `product_catalog_test.go`,
`customer_mapping_test.go`, `webhook_invoice_test.go`,
`webhook_payment_method_test.go`, and the reconcile tests under
`internal/fulfillment`.
## 3. The agree helper and its six consumers (design D2, D5)
- [x] 3.1 `MappingAgrees(livemode sql.NullBool, keyMode string) bool` in the
stripe store package (`internal/integrations/stripe/store`), true
for NULL and for a match, with a unit test over the three cases, so
every consumer asks one question (design D2).
- [x] 3.2 `internal/server/product_readiness.go`: `syncedPaymentDetail`
takes the mapping's state, not the key's mode alone, and returns
the five strings of design D5: `Synced, unverified`;
`Synced, live` / `Synced, test`; `Synced in test, key is live` /
`Synced in live, key is test`; `Not found in live mode` /
`Not found in test mode`.
- [x] 3.3 Same file: `PriceReadiness` and `priceReadinessFromBatches` carry
the mapping's `livemode`, `verified_at` and `sync_status` from
`computePriceReadiness`'s `internalstripe.PriceMapping`, and
`buildProductReadinessVM` leaves the Payment processing row unmet
on a disagreeing or stale mapping, verdict
`Incomplete; missing: payment processing` (design D5), fed from the
two `stripeMode: h.StripeMode` call sites in
`internal/server/operator_products.go` (around lines 677 and 824),
which pass the mapping state through `readinessInputs`.
- [x] 3.4 `internal/server/operator_billing.go`, `SyncProductToStripe`: the
two `This price is already synced to Stripe.` refusals (lines 1100
and 1154) fire only when the mapping agrees and is not stale;
otherwise the handler enqueues the outbox create so the object is
made again in the current world and the new mapping replaces the
old id (design D5).
- [x] 3.5 The readiness panel's Sync control
(`operator_product_readiness.html`, the `Sync to Stripe` button) is
relabelled `Create in live` (or `Create in test`) when it will
create again, from a label the readiness view model carries; no
confirm modal, because the modal is the destructive idiom and the
row beside the control already states why the old id is no good
(design D5).
- [x] 3.6 `internal/server/billing.go`, `HandleCheckout`: refuse a price
whose mapping disagrees or is stale before any Stripe call, with
the existing string at line 123, `price not yet available in
Stripe` (design D5: the same fact, said earlier).
- [x] 3.7 Same file, `createStripeCustomer`: a disagreeing or stale customer
mapping is treated as absent, so the customer is created again in
the current world.
- [x] 3.8 `internal/fulfillment/reconcile.go`, `ReconcileSubscription`: skip
the subscription and log `reconcile: subscription mapping recorded
in test, key is live; skipping` with the row id, the Stripe id,
`recorded_mode` and `key_mode`; a stale row logs `stale` in place
of the recorded mode (design D5).
- [x] 3.9 `internal/fulfillment/plan_change.go` (`SwitchPlan` through
`resolveLadderSubscription`) refuses before the call and logs
`plan_change: ...`; `internal/fulfillment/sweep.go`
(`fireScheduledChange`) skips and logs `sweep: ... skipping
scheduled change`, both in the same shape as 3.8.
- [x] 3.10 Tests across the six consumers that unverified is not a
disagreement anywhere (design D5: production keeps working between
the migration and the first check).
## 4. The fingerprint, the check, and the provider page section (design D3)
- [x] 4.1 A key fingerprint helper in
`internal/integrations/stripe/stripe.go` beside `ModeForKey`:
SHA-256 of the raw key, digest only, the shape
`internal/auth/auth.go` already uses at lines 488 and 1115.
- [x] 4.2 `cmd/start.go`: compute the fingerprint beside the `ModeForKey`
call at line 125 and carry it to the boot step, as `stripeMode` is
carried to `Config.StripeMode` at line 402.
- [x] 4.3 `cmd/start.go` boot: with Stripe configured, read
`stripe.environment_check`; when the recorded fingerprint differs
or no record exists, clear `verified_at` on the product and price
mappings and start the check workflow. Start it, do not await it,
following the entitlement recompute poller's `ExecuteWorkflow`
block at line 312; a failed start logs and boot continues.
- [x] 4.4 `StripeEnvironmentCheckWorkflow` in
`internal/integrations/stripe/workflows`, workflow id
`stripe-environment-check`, so a second start while one runs is
deduplicated by Temporal (design D3).
- [x] 4.5 The check activity: list every product and price mapping with a
Stripe id, then `product.Get` or `price.Get` one at a time under
the current key, heartbeating per row. Resolved takes the object's
`livemode` and `verified_at = now()` and returns a `stale` row to
`synced`; `ErrorCodeResourceMissing` takes `sync_status = 'stale'`
and `verified_at = now()` plus one log line, `stripe: mapping id
missing under the current key`; any other error fails the activity
for Temporal's retry policy.
- [x] 4.6 On completion the activity writes the setting
(`key_fingerprint`, `started_at`, `finished_at`, `checked`,
`stale`) and logs `stripe: environment check complete`; the start
logs `stripe: environment check started` with `mappings`, `mode`,
the fingerprint prefix and `trigger=boot` or `trigger=operator`.
- [x] 4.7 `internal/server/operator_integrations_stripe.go`: an
`EnvironmentCheck` field and `EnvironmentCheckHeader
SectionHeader{Title: "Environment check"}` on
`StripeIntegrationData`, loaded in `GetStripeIntegrationPage`
between the details block (`DetailsHeader` in `anatomy.go`) and
`DeliveryQueueHeader`.
- [x] 4.8 The resting line, by state (design D3): `Not checked yet.`;
`Checking 36 Stripe ids under the current key.`; `Last checked Sep
18, 2026 3:04 PM. 34 verified, 2 stale.` in the page's existing
`Jan 2, 2006` date format; `API key changed since the last check.
34 mappings unverified.`
- [x] 4.9 The section in
`internal/embeds/templates/partials/operator_integration_stripe.html`:
its resting line and one outline control, `Check now`,
after the `sectionHeader` for the details block.
- [x] 4.10 A new POST route on the provider page, registered in
`internal/server/operator_partials.go` beside the `sync-stripe`
route at line 333, with its handler starting the same workflow
with `trigger=operator`, and its declaration in
`internal/server/action_triggers.go` beside the `Sync to Stripe`
entry at line 49. Its toasts read `36 checked, 2 stale.` when the
workflow finishes inside the request's wait and `Check started.`
when it does not (design D3).
- [x] 4.11 Activity tests against `internal/stripetest/mock.go`: canned
`livemode` values for the resolving case, a `resource_missing`
answer for the stale case, and a third error the activity returns
for retry. A boot test that a matching fingerprint starts nothing.
## 5. Events from the other world (design D6)
- [x] 5.1 `internal/integrations/stripe/web/webhook.go`, `ServeHTTP`: the
insert into `core.webhook_events` (line 162) gains
`provider_environment`, written `live` or `test` from the
envelope's `Livemode`, the value the handler discards today.
- [x] 5.2 Same insert: an event whose world differs from the key's mode is
stored with `status = 'refused'` and starts no workflow, and the
endpoint answers 200 so Stripe stops redelivering. `refused` stays
outside `unfinishedStatuses` (line 91), so a redelivery answers 200
without restarting anything. The log line reads `stripe: webhook
event mode disagrees with the key; captured, not processed` with
`event_id`, `event_type`, `event_mode` and `key_mode`.
- [x] 5.3 `loadInboundEvents` in
`internal/server/operator_integrations_stripe.go` counts refused
rows, and the Inbound events section renders one line only when the
count is above zero: `3 events arrived in test mode under a live
key.`
- [x] 5.4 `loadInboundDeadLetterEntries` and the Inbound events table in
`operator_integration_stripe.html`: a refused row carries the
detail `Refused, mode mismatch`.
- [x] 5.5 Handler tests: a disagreeing event is stored refused with 200 and
no workflow start; its redelivery answers 200; an agreeing event is
unchanged. A render test for the line and the row detail.
## 6. The billing views show the current environment (design D7)
- [x] 6.1 The filter follows the cross-schema precedent already in
`operator_billing.go`, not a predicate inside the core query: the
operator list views page over `billing.List*Page` and look each
row's mapping up separately, so resolve the ids in the stripe store
and pass them into the paged query the way
`matchingInvoiceIDsByStripeNumber` feeds `InvoiceIds` into
`ListInvoicesPage`. Filtering after `FetchPage` would corrupt the
pager's totals.
- [x] 6.2 New stripe-store queries returning the mapping-held ids whose
`livemode` equals the key's mode or is NULL, and the count of the
ids it excludes, for the customer, subscription, invoice and
payment mapping tables.
- [x] 6.3 `loadBillingAccountsData`, `loadSubscriptionsData`,
`loadInvoicesData` and `loadPaymentsData` in `operator_billing.go`:
each applies the filter and carries the hidden count into its
`*Data`.
- [x] 6.4 `internal/server/list_scale.go`: `env=all` travels through
`ListNav.Extra` so the search form, the facet, the pager and the
page-size links preserve it; the four `*ListNav` builders in
`operator_billing.go` read the parameter.
- [x] 6.5 The absence line above each view, muted, rendered only when rows
are hidden: `12 invoices from test mode are not shown. Show all`;
in the all state, `Showing all environments. Show live only` (or
`test only`). Templates: `operator_billing_accounts.html`,
`operator_subscriptions.html`, `operator_invoices.html`,
`operator_payments.html`.
- [x] 6.6 In the all state, every row from the other world carries a badge
through the `statusBadge` part: `internal/server/anatomy.go` gains
a `test` state in `badgeMap` and `knownStates`, secondary tone,
beside the existing `live` state (success tone, line 243), which
the rows reuse (design D7).
- [x] 6.7 `internal/server/member_invoices.go`, `GetInvoices`: the member's
own invoices are filtered to the key's world with no switch and no
line (design D7, and the open question the maintainer reviews).
The view is unpaged, so the filter applies in Go after
`GetInvoicesByBillingAccountID`.
- [x] 6.8 Tests: each operator view hides the other world's rows and counts
them, `env=all` shows them with a badge and survives a page change
and a facet change, NULL rows show under either key, and the member
view carries no switch.
## 7. Docs
- [x] 7.1 `docs/stripe.md` gains `## Moving between environments` after
`## Configuration` and before `## Webhook endpoint`: swap the API
key and the webhook secret together, what happens to the old
mappings, what the check does at the next boot and where the
operator reads its outcome, and the Sync step that recreates each
product in the new world (design D8).
- [x] 7.2 `docs/design-system.md`: the `statusBadge` row in the parts table
(line 476) names tones, not states, so check whether the badge
map's states are enumerated anywhere in the doc before adding the
environment states; if they are not, record that no doc edit is
owed and leave the file alone.
Checked 2026-09-20: the doc names tones only and enumerates no
badge state, so no doc edit is owed; the map in `anatomy.go` and
`TestStatusBadgeMapIsComplete` carry `test` and `stale`.
## 8. Verification
- [x] 8.1 `make lint` and `make lint-templates` green, including the
`filler-copy` rule over the new resting lines, absence lines and
readiness strings.
- [x] 8.2 `make test` green, and the targeted packages first:
`go test ./internal/integrations/stripe/... ./internal/fulfillment/
./internal/server/ ./internal/instance/`.
- [x] 8.3 `make screens` against the running stack; the captures expected to
change are `operator-billing-accounts`,
`operator-billing-subscriptions`, `operator-billing-invoices`,
`operator-billing-payments`, `operator-billing-invoice-detail`,
`operator-integration-stripe` and `member-billing` in
`test/e2e/screens/manifest.go`, plus `operator-product-detail`,
which renders the readiness panel. Name the sheets as absolute
`file:///` URLs for the maintainer.
Run 2026-09-20: `operator-integration-stripe` changed (the Environment
check section, desktop and mobile); the four billing lists, the
product detail and `member-billing` came out the same because the
demo seed holds no Stripe mapping, so nothing is out of mode and no
line, switch or badge renders; `operator-person-detail` and
`operator-billing-invoice-detail` differ only in demo ids, the
snapshot having been rebuilt for the two migrations.
- [x] 8.4 A walkthrough on the test stack (`./bootstrap-stack.sh` then
`docker compose up` in `test/`) driving design D8's sandbox-to-live
sequence as far as the fake backend allows: stamp a product and its
price, change the key's mode, restart, and read the readiness row
(`Synced in test, key is live`), the refused checkout, the
environment check section, a refused webhook event, and the billing
views' absence line with its switch.
Walked 2026-09-20 against the running stack with the real test key
(no fake backend): Sync on demo-plan created the product and price
in the sandbox and stamped both mappings `livemode=false`; the
check answered `2 checked, 0 stale.` and set `verified_at`; with
both rows flipped to `livemode=true` in the database the panel read
`Synced in live, key is test`, the control `Create in test`, and a
member checkout POST answered 400 with the refusal logged as
`recorded_mode=live key_mode=test`; with the ids replaced by ones
the key cannot find, the check answered `2 checked, 2 stale.`, the
panel `Not found in test mode` with a `Stale` price badge; Bob's
account and three invoices recorded live gave `3 invoices from live
mode are not shown. Show all` and `1 billing account from live mode
is not shown. Show all` with view-scoped pagers, `env=all` gave
`Showing all environments. Show test only` with `Live` badges, and
subscriptions (nothing hidden) rendered no line; a signed
`customer.updated` event with `livemode: true` was stored `refused`
with `provider_environment = live` and the provider page read `1
event arrived in live mode under a test key.` with the row `Refused,
mode mismatch`. The key's mode was not changed (the test stack
holds one key), so the fingerprint-differs boot path is covered by
`TestEnvironmentCheckNeeded` and the workflow tests only.
- [x] 8.5 Record the production rollout note from design D8: the migration
adds a nullable column to nine tables and no data; the first boot
after deploy finds no fingerprint record and runs the check over
wiki.cafe's live products and prices, a few dozen GETs, which
settles them `Synced, live` with no operator step.
Recorded 2026-09-20 in `status/issues.md`, "Stripe environment stamp
rollout notes" under Billing, Stripe & purchasability.
## 9. Issues, review and archive
- [x] 9.1 `status/issues.md`, the "Billing, Stripe & purchasability"
section (line 155): log the follow-up the Risks section names,
customers are not read back, so a same-mode move leaves a customer
id dangling until a checkout fails; extending the check to
`customer_mappings` is one more loop over one more table.
- [x] 9.2 Maintainer reviews the screens sheets and the four points the
design left open: the stale wording (`Not found in live` against
`Missing in live`), the member invoices filter, the Sync control's
relabel in place of a confirm sentence, and the `Test` badge's
secondary tone.
Reviewed 2026-09-20: the four points accepted as built; the check
control relabeled `Check now` at the review; screens accepted.
- [x] 9.3 Archive with the implementation's commit, after the harvest check
in `status/MAINTAINING.md` "Landing a change"; close the issue at
line 284 of `status/issues.md` into
`status/archive/issues-resolved.md`; run `make screens-accept`.
Landed 2026-09-20 on the maintainer's go: notebook harvested (one
clause into `docs/stripe.md`, the rest already in design and docs),
issue closed into the resolved archive, screens accepted, specs
synced, change archived; the commit is the maintainer's.

Some files were not shown because too many files have changed in this diff Show More