Section 8 of #212's change, after review: the Billing card's muted line under the Subscriptions row becomes an amber "Not delivering" badge on the row's value line, after the state badge, with a title that names the ladders and the holder ("Billing continues while a grant holds Demo Ladder."; with several subscriptions "Billing continues on <k> of <n> subscriptions while ..."). The operator's subscriptions list marks each displaced subscription's row with the same badge and title, read with one grouped statement per page and turned into titles by the same steps the card uses. A failed read on the list says "Failed to load deliveries." once above the table and marks no row. A paused subscription no longer counts: it generates no invoice, so "Billing continues" would be false; it is marked once it is active again. The grant form's warning now reads "Products marked ⚠ would supersede a tier a subscription pays for; the subscription keeps billing and delivers nothing on that ladder until the grant ends." The billing view it named before can change nothing. A Stripe-paused subscription's tier is still marked, an accepted edge until #269's change, which pauses a displaced subscription's billing and rewords the badge and the warning. Goldens: 16 change, each as item 8.9 lists: the organization page's JSON goldens rename DisplacementNote to DisplacementTitle, the two subscriptions-list goldens gain the title field, three HTML goldens carry the new warning, and grant-over-subscription.html the badge in place of the line. No other golden changed. Breaks (scripts/mutation-check, 31 of 31 CAUGHT): - paused restored - not_delivering removed from badgeMap - badge tone - list badge without title - card title never set - card template without badge - account form never chosen - n read as displaced count - subscription holder as grant - several holders as one - ladders joined with commas - sort removed - ladders not deduplicated - holders not deduplicated - status filter dropped (card) - status filter dropped (list) - list row in account form - list row in account form (list vs card) - list titles never set (the field set to strings.TrimPrefix of the title by itself, an empty string, because removing the line leaves titles unused and does not compile) - delivering row given the displaced title - list template without badge - list pool not default - list reads moved into the row loop (emulated: an inline closure calls the title read once per row; a helper only the break called would fail deadcode) - list DisplacementError assignment and L4 removed - O4 template line removed - O4 line moved under the table - rows dropped on a failed read - badge label - card badge without title - delivering row given an entry - old warning restored Hand capture on the Stripe sandbox (API 2025-02-24.acacia): a member subscribed to a $20/month tier through Checkout; an operator issued a complimentary grant of the tier above; an edit to the subscription at Stripe then reconciled it, and the reconcile logged "reconcile: product not conferred; another plan holds its ladder". The Billing card and the subscription's list row each showed Active and Not delivering at 1280 and at the 390 mobile width, with no sideways page scroll; the badge's title read by script: "Billing continues while a grant holds Demo Ladder." Captures under test/screens/displaced-subscription-waits/. The suite on reset databases passed on this section's tree, 4711 tests, 14 skipped; make screens changed no screen this section owns, and every registered form was captured. The review's last fixes (the list test's row checks now fail when a row is missing, two comment rewraps, task wording) came after it; the list test and its five breaks were rerun on them: pass, 5 of 5 CAUGHT. make lint passes; openspec validate --strict passes.
244 lines
7.6 KiB
Go
244 lines
7.6 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.29.0
|
|
// source: subscription_items.sql
|
|
|
|
package billing
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
const createSubscriptionItem = `-- name: CreateSubscriptionItem :one
|
|
INSERT INTO core.subscription_items (subscription_id, product_id, price_id, quantity)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING subscription_item_id, subscription_id, product_id, price_id, quantity, created_at, updated_at, ended_at
|
|
`
|
|
|
|
type CreateSubscriptionItemParams struct {
|
|
SubscriptionID string `json:"subscription_id"`
|
|
ProductID string `json:"product_id"`
|
|
PriceID string `json:"price_id"`
|
|
Quantity int32 `json:"quantity"`
|
|
}
|
|
|
|
func (q *Queries) CreateSubscriptionItem(ctx context.Context, arg CreateSubscriptionItemParams) (SubscriptionItem, error) {
|
|
row := q.db.QueryRowContext(ctx, createSubscriptionItem,
|
|
arg.SubscriptionID,
|
|
arg.ProductID,
|
|
arg.PriceID,
|
|
arg.Quantity,
|
|
)
|
|
var i SubscriptionItem
|
|
err := row.Scan(
|
|
&i.SubscriptionItemID,
|
|
&i.SubscriptionID,
|
|
&i.ProductID,
|
|
&i.PriceID,
|
|
&i.Quantity,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.EndedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const endSubscriptionItem = `-- name: EndSubscriptionItem :execrows
|
|
UPDATE core.subscription_items
|
|
SET ended_at = NOW()
|
|
WHERE subscription_item_id = $1 AND ended_at IS NULL
|
|
`
|
|
|
|
// Mark an item as no longer on its subscription (reconcile, when its Stripe
|
|
// item is gone from the subscription). A row already ended is left as it is.
|
|
func (q *Queries) EndSubscriptionItem(ctx context.Context, subscriptionItemID string) (int64, error) {
|
|
result, err := q.db.ExecContext(ctx, endSubscriptionItem, subscriptionItemID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected()
|
|
}
|
|
|
|
const getSubscriptionItemByID = `-- name: GetSubscriptionItemByID :one
|
|
SELECT subscription_item_id, subscription_id, product_id, price_id, quantity, created_at, updated_at, ended_at FROM core.subscription_items
|
|
WHERE subscription_item_id = $1
|
|
`
|
|
|
|
func (q *Queries) GetSubscriptionItemByID(ctx context.Context, subscriptionItemID string) (SubscriptionItem, error) {
|
|
row := q.db.QueryRowContext(ctx, getSubscriptionItemByID, subscriptionItemID)
|
|
var i SubscriptionItem
|
|
err := row.Scan(
|
|
&i.SubscriptionItemID,
|
|
&i.SubscriptionID,
|
|
&i.ProductID,
|
|
&i.PriceID,
|
|
&i.Quantity,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.EndedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getSubscriptionItemsBySubscriptionID = `-- name: GetSubscriptionItemsBySubscriptionID :many
|
|
SELECT subscription_item_id, subscription_id, product_id, price_id, quantity, created_at, updated_at, ended_at FROM core.subscription_items
|
|
WHERE subscription_id = $1 AND ended_at IS NULL
|
|
ORDER BY created_at ASC
|
|
`
|
|
|
|
// The subscription's current items: an item Stripe replaced (a schedule's
|
|
// phase change does) keeps its row for the invoice lines that reference it,
|
|
// with ended_at set, and is not returned.
|
|
func (q *Queries) GetSubscriptionItemsBySubscriptionID(ctx context.Context, subscriptionID string) ([]SubscriptionItem, error) {
|
|
rows, err := q.db.QueryContext(ctx, getSubscriptionItemsBySubscriptionID, subscriptionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []SubscriptionItem{}
|
|
for rows.Next() {
|
|
var i SubscriptionItem
|
|
if err := rows.Scan(
|
|
&i.SubscriptionItemID,
|
|
&i.SubscriptionID,
|
|
&i.ProductID,
|
|
&i.PriceID,
|
|
&i.Quantity,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.EndedAt,
|
|
); 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 listCurrentSubscriptionItemsByBillingAccountID = `-- name: ListCurrentSubscriptionItemsByBillingAccountID :many
|
|
SELECT si.subscription_id, si.product_id
|
|
FROM core.subscription_items si
|
|
JOIN core.subscriptions s USING (subscription_id)
|
|
WHERE s.billing_account_id = $1::uuid
|
|
AND si.ended_at IS NULL
|
|
ORDER BY si.subscription_id, si.product_id
|
|
`
|
|
|
|
type ListCurrentSubscriptionItemsByBillingAccountIDRow struct {
|
|
SubscriptionID string `json:"subscription_id"`
|
|
ProductID string `json:"product_id"`
|
|
}
|
|
|
|
// The current items of every subscription of the billing account, the rows
|
|
// mirrorItems writes, in one statement for the organization page's Billing
|
|
// card (displaced-subscription-waits D4).
|
|
func (q *Queries) ListCurrentSubscriptionItemsByBillingAccountID(ctx context.Context, billingAccountID string) ([]ListCurrentSubscriptionItemsByBillingAccountIDRow, error) {
|
|
rows, err := q.db.QueryContext(ctx, listCurrentSubscriptionItemsByBillingAccountID, billingAccountID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListCurrentSubscriptionItemsByBillingAccountIDRow{}
|
|
for rows.Next() {
|
|
var i ListCurrentSubscriptionItemsByBillingAccountIDRow
|
|
if err := rows.Scan(&i.SubscriptionID, &i.ProductID); 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 listCurrentSubscriptionItemsBySubscriptionIDs = `-- name: ListCurrentSubscriptionItemsBySubscriptionIDs :many
|
|
SELECT si.subscription_id, si.product_id
|
|
FROM core.subscription_items si
|
|
WHERE si.subscription_id = ANY($1::uuid[])
|
|
AND si.ended_at IS NULL
|
|
ORDER BY si.subscription_id, si.product_id
|
|
`
|
|
|
|
type ListCurrentSubscriptionItemsBySubscriptionIDsRow struct {
|
|
SubscriptionID string `json:"subscription_id"`
|
|
ProductID string `json:"product_id"`
|
|
}
|
|
|
|
// The current items of one page of the operator's subscriptions list, the
|
|
// rows mirrorItems writes, in one statement for the page
|
|
// (displaced-subscription-waits D4).
|
|
func (q *Queries) ListCurrentSubscriptionItemsBySubscriptionIDs(ctx context.Context, subscriptionIds []string) ([]ListCurrentSubscriptionItemsBySubscriptionIDsRow, error) {
|
|
rows, err := q.db.QueryContext(ctx, listCurrentSubscriptionItemsBySubscriptionIDs, pq.Array(subscriptionIds))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListCurrentSubscriptionItemsBySubscriptionIDsRow{}
|
|
for rows.Next() {
|
|
var i ListCurrentSubscriptionItemsBySubscriptionIDsRow
|
|
if err := rows.Scan(&i.SubscriptionID, &i.ProductID); 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 updateSubscriptionItemPriceProduct = `-- name: UpdateSubscriptionItemPriceProduct :one
|
|
UPDATE core.subscription_items
|
|
SET product_id = $2, price_id = $3, quantity = $4
|
|
WHERE subscription_item_id = $1
|
|
RETURNING subscription_item_id, subscription_id, product_id, price_id, quantity, created_at, updated_at, ended_at
|
|
`
|
|
|
|
type UpdateSubscriptionItemPriceProductParams struct {
|
|
SubscriptionItemID string `json:"subscription_item_id"`
|
|
ProductID string `json:"product_id"`
|
|
PriceID string `json:"price_id"`
|
|
Quantity int32 `json:"quantity"`
|
|
}
|
|
|
|
// Converge a core item row after an in-place Stripe price modification (paid->paid
|
|
// switch): the Stripe item id is unchanged but it now carries a new price/product.
|
|
func (q *Queries) UpdateSubscriptionItemPriceProduct(ctx context.Context, arg UpdateSubscriptionItemPriceProductParams) (SubscriptionItem, error) {
|
|
row := q.db.QueryRowContext(ctx, updateSubscriptionItemPriceProduct,
|
|
arg.SubscriptionItemID,
|
|
arg.ProductID,
|
|
arg.PriceID,
|
|
arg.Quantity,
|
|
)
|
|
var i SubscriptionItem
|
|
err := row.Scan(
|
|
&i.SubscriptionItemID,
|
|
&i.SubscriptionID,
|
|
&i.ProductID,
|
|
&i.PriceID,
|
|
&i.Quantity,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.EndedAt,
|
|
)
|
|
return i, err
|
|
}
|