Derive Stripe test/live mode from the API key prefix at boot, failing on unrecognized prefixes, and drop the separate `stripe-mode` config key. Refine disabled controls to render through the shared `disabledControl` part with the not-allowed cursor, and add a lint rule refusing hand-rolled disabled buttons. Adjust plan cards to offer no purchase control on free rungs, fix bound checkbox Bool handling, and rename "Public/Private" to "Listed/Unlisted" with enhanced readiness verdicts.
374 lines
14 KiB
Go
374 lines
14 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server
|
|
|
|
import (
|
|
"html/template"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
|
|
)
|
|
|
|
// The product form (spec product-management "Operator product creation",
|
|
// "Operator product editing"; spec form-library; design D2, D5, D8, D14).
|
|
// One declaration serves the create page at /operator/products/new and the
|
|
// edit form on the product's record page, rendered in two modes, so the
|
|
// two cannot expose different field sets: the eleven differences the forms
|
|
// audit found between six create and edit forms (findings FA-1 to FA-5,
|
|
// FA-12) become one list with three declared sides. None of the three
|
|
// renders a notice: a field simply absent from the other side needs no
|
|
// sentence saying so (maintainer, 2026-09-03).
|
|
//
|
|
// This file is the form's home; the registry is only the index (design
|
|
// D1). It sits beside operator_products.go, which parses through the
|
|
// declaration and never through r.FormValue.
|
|
|
|
// productFormName is the declaration's name, its data-form id, and the id
|
|
// prefix of every one of its fields.
|
|
const productFormName = "operator.product"
|
|
|
|
// productSetOptionNew is the create side's first and default
|
|
// entitlement-set option: choosing it creates the set with the product, in
|
|
// one transaction, so a deployment with no set yet can still create its
|
|
// first product (design D14). It is not a set identifier and never reaches
|
|
// the database as one.
|
|
const productSetOptionNew = "new"
|
|
|
|
// productForm is the declaration, registered as this package is
|
|
// initialised rather than from the handler's constructor, so the registry
|
|
// is complete for the invariants test, the route test and the
|
|
// registered-forms table whether or not a handler has been built.
|
|
var productForm = forms.Register(forms.FormSpec{
|
|
Name: productFormName,
|
|
Kind: forms.KindCreate,
|
|
Family: forms.Stacked,
|
|
Method: "POST",
|
|
Path: "/partials/operator/products",
|
|
// The record side is a different route: the create page posts to the
|
|
// collection, the edit form puts to the record.
|
|
EditMethod: "PUT",
|
|
EditPath: "/partials/operator/products/{productID}",
|
|
// Both sides re-render the operator body, because saving a product
|
|
// changes its readiness panel and its prices view as well as the
|
|
// form, and a refusal must swap into the same target the success
|
|
// does (design D9, lesson L§17).
|
|
Target: "#operator-body",
|
|
Swap: "innerHTML",
|
|
Fields: []forms.Field{
|
|
productNameField(),
|
|
{
|
|
Name: "display_category",
|
|
Label: "Display category",
|
|
Control: forms.Text,
|
|
Optional: true,
|
|
MaxLen: 100,
|
|
Placeholder: "e.g. addon",
|
|
// A free-form grouping label with no behavioural weight, and
|
|
// no fixed domain: any string, blank included, is accepted
|
|
// (Doc 41 Decision 136). The explanation the detail page
|
|
// carries is the explanation the create page carries.
|
|
Help: forms.Help("Display category", "Grouping for the storefront and operator lists; never drives behavior. Blank is fine."),
|
|
Autocomplete: "off",
|
|
},
|
|
{
|
|
Name: "entitlement_set_id",
|
|
Label: "Entitlement set",
|
|
Control: forms.Select,
|
|
// The options are the deployment's active sets plus the
|
|
// side's own first option, so they are supplied per request
|
|
// and the same list is given to ParseWith.
|
|
RuntimeOptions: true,
|
|
Hint: "The set is what the product delivers; a product with none delivers no capabilities.",
|
|
},
|
|
{
|
|
Name: "visibility",
|
|
Label: "Listed",
|
|
Control: forms.Checkbox,
|
|
Value: "public",
|
|
// One checkbox, not a pair of radios and not two switches:
|
|
// the model records only is_public, and an unlisted product is
|
|
// the one way to grant a set (Doc 41; walk run 2, ACC-12).
|
|
// The field name and its value stay "visibility"/"public";
|
|
// only the operator-facing words change (design D4).
|
|
Help: forms.Help("Listed", "Shown in the member catalog."),
|
|
},
|
|
// The three edit-only fields: the create page publishes the
|
|
// product in one step, so its write-up, its feature list and its
|
|
// Active state all follow creation. None declares a Notice,
|
|
// because a field simply absent from the create page needs no
|
|
// sentence saying so (maintainer, 2026-09-03).
|
|
forms.RecordDescription().On(forms.EditOnly),
|
|
productFeaturesField(),
|
|
productActiveField(),
|
|
productExcludeField(),
|
|
},
|
|
Commit: "Create product",
|
|
CommitEdit: "Save changes",
|
|
WayOut: forms.LinkOut("Cancel", "/operator/products"),
|
|
})
|
|
|
|
// productNameField is the record name plus the duplicate-name warning's
|
|
// live region (spec product-management "Duplicate product name warns
|
|
// without blocking"). core.products.name carries no uniqueness
|
|
// constraint, so same-named products are legitimate; the form warns and
|
|
// never blocks. The scoped request swaps into the field's declared slot,
|
|
// whose id is escaped for a CSS selector because the form's name carries a
|
|
// dot. hx-include sends the form's own fields, which is how the edit side
|
|
// tells the check to exclude the product being edited.
|
|
func productNameField() forms.Field {
|
|
f := forms.RecordName()
|
|
f.Slot = "warning"
|
|
f.Attrs = map[string]string{
|
|
"hx-get": "/partials/operator/products/name-check",
|
|
"hx-trigger": "input changed delay:300ms",
|
|
"hx-target": `#form-operator\.product-name-warning`,
|
|
"hx-swap": "innerHTML",
|
|
"hx-include": "closest form",
|
|
}
|
|
return f
|
|
}
|
|
|
|
// productFeaturesField is the free-text feature list the member Products
|
|
// page shows, one per line. Edit-only: the product is created and
|
|
// published in one step, and its write-up follows (findings FA-2, FA-3).
|
|
// One line under the control, its hint; the side's reason is declared and
|
|
// not rendered.
|
|
func productFeaturesField() forms.Field {
|
|
f := forms.Field{
|
|
Name: "features",
|
|
Label: "Additional features",
|
|
Control: forms.Textarea,
|
|
Optional: true,
|
|
Rows: 3,
|
|
MaxLen: 2000,
|
|
Hint: "Shown on the Products page, one per line; supplements entitlement-derived limits.",
|
|
}
|
|
return f.On(forms.EditOnly)
|
|
}
|
|
|
|
// productActiveField is the product's Active state. Edit-only, because a
|
|
// product is created active; the help icon after the label explains what
|
|
// inactive means, so the operator meets the concept where it can be acted
|
|
// on (finding FA-3).
|
|
func productActiveField() forms.Field {
|
|
f := forms.Field{
|
|
Name: "is_active",
|
|
Label: "Active",
|
|
Control: forms.Checkbox,
|
|
Value: "true",
|
|
Help: forms.Help("Active", "An inactive product is not offered when new grants are created. Grants that already reference it keep working."),
|
|
}
|
|
return f.On(forms.EditOnly)
|
|
}
|
|
|
|
// productExcludeField carries the product being edited to the
|
|
// duplicate-name check, through the name field's hx-include. It writes
|
|
// nothing and renders nothing.
|
|
func productExcludeField() forms.Field {
|
|
f := forms.Field{
|
|
Name: productExcludeName,
|
|
Label: "Excluded product",
|
|
Control: forms.Hidden,
|
|
Optional: true,
|
|
}
|
|
return f.On(forms.EditOnly)
|
|
}
|
|
|
|
// productExcludeName is the edit side's hidden carrier for the
|
|
// duplicate-name check: the product being edited must not warn about its
|
|
// own name. It writes nothing; it rides the name field's hx-include so the
|
|
// check can leave this product out, and UpdateProduct ignores it, because
|
|
// the route and not the field is the authority on which product is being
|
|
// edited (finding FA-10).
|
|
const productExcludeName = "exclude"
|
|
|
|
// productSetOptions builds the entitlement-set select's options for one
|
|
// side. The create side opens on "New set named after this product"; the
|
|
// edit side opens on "None", whose consequence the field's hint states.
|
|
// The active sets follow, plus, on the edit side, the product's own set
|
|
// when it has been deactivated since (so Save round-trips the existing
|
|
// assignment instead of silently rewriting it).
|
|
func productSetOptions(side forms.Side, sets []EntitlementSetOption) []forms.Option {
|
|
var out []forms.Option
|
|
if side == forms.EditOnly {
|
|
out = append(out, forms.NoneOption())
|
|
} else {
|
|
out = append(out, forms.Option{Value: productSetOptionNew, Label: "New set named after this product"})
|
|
}
|
|
for _, s := range sets {
|
|
label := s.Name
|
|
if s.Inactive {
|
|
label += " (current; inactive)"
|
|
}
|
|
out = append(out, forms.Option{Value: s.SetID, Label: label})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// productFormOptions is the option map both the render and the parse read,
|
|
// so what the control offered and what the handler accepts are one list.
|
|
func productFormOptions(side forms.Side, sets []EntitlementSetOption) map[string][]forms.Option {
|
|
return map[string][]forms.Option{"entitlement_set_id": productSetOptions(side, sets)}
|
|
}
|
|
|
|
// productCreateForm renders the create page's form: unbound on a first
|
|
// visit, submission-bound on a refusal.
|
|
func productCreateForm(sets []EntitlementSetOption, values forms.Values, errs *forms.Errors) forms.FormView {
|
|
mode := forms.ModeUnbound
|
|
if errs.Any() {
|
|
mode = forms.ModeSubmission
|
|
}
|
|
return forms.Render(productForm, forms.Binding{
|
|
Mode: mode,
|
|
Side: forms.CreateOnly,
|
|
Values: values,
|
|
Errors: errs,
|
|
Options: productFormOptions(forms.CreateOnly, sets),
|
|
})
|
|
}
|
|
|
|
// productEditForm renders the record page's form, bound to the product or
|
|
// to a refused submission of it.
|
|
func productEditForm(productID string, sets []EntitlementSetOption, values forms.Values, errs *forms.Errors, slots map[string]template.HTML) forms.FormView {
|
|
mode := forms.ModeRecord
|
|
if errs.Any() {
|
|
mode = forms.ModeSubmission
|
|
}
|
|
action, err := web.RouteURL(productForm.EditPath, productID)
|
|
if err != nil {
|
|
action = productForm.Path
|
|
}
|
|
return forms.Render(productForm, forms.Binding{
|
|
Mode: mode,
|
|
Side: forms.EditOnly,
|
|
Action: action,
|
|
Values: values,
|
|
Errors: errs,
|
|
Options: productFormOptions(forms.EditOnly, sets),
|
|
Slots: slots,
|
|
})
|
|
}
|
|
|
|
// productEditSlots builds the edit form's one live region: the
|
|
// duplicate-name warning under the name field, rendered at load so the
|
|
// no-JavaScript path and the "someone else created it since" case are both
|
|
// covered. It is the only slot the product form declares; the
|
|
// cross-reference to the assigned set's rules that once sat under the
|
|
// entitlement-set select is gone (maintainer, 2026-09-03: "It seems
|
|
// superfluous. Remove.").
|
|
func productEditSlots(t *SafeTemplates, data OperatorProductEditData) map[string]template.HTML {
|
|
if len(data.NameWarning.Matches) == 0 {
|
|
return nil
|
|
}
|
|
return map[string]template.HTML{
|
|
"name": t.Fragment("operator_product_name_warning.html", data.NameWarning),
|
|
}
|
|
}
|
|
|
|
// productEditValues binds a product record to the declaration's fields.
|
|
func productEditValues(p OperatorProductViewModel) forms.Values {
|
|
values := forms.NewValues()
|
|
values.Set("name", p.Name)
|
|
values.Set("display_category", p.DisplayCategory)
|
|
values.Set("entitlement_set_id", p.EntitlementSetID)
|
|
values.Set("description", p.Description)
|
|
values.Set("features", p.Features)
|
|
values.Set(productExcludeName, p.ProductID)
|
|
if f, ok := productForm.Field("visibility"); ok {
|
|
values.SetBool(f, p.IsPublic)
|
|
}
|
|
if f, ok := productForm.Field("is_active"); ok {
|
|
values.SetBool(f, p.IsActive)
|
|
}
|
|
return values
|
|
}
|
|
|
|
// The price-add form (spec product-management "Prices are added from a
|
|
// closed panel"; spec form-library; design D10). A dense sub-record panel
|
|
// beside the product's own declaration (lane B).
|
|
|
|
const productPriceFormName = "operator.product.price.add"
|
|
|
|
// productPriceAddPanelID is the collapse element the price form's Cancel
|
|
// closes (operator_product_prices.html's own panel wrapper).
|
|
const productPriceAddPanelID = "createPricePanel"
|
|
|
|
var productPriceForm = forms.Register(forms.FormSpec{
|
|
Name: productPriceFormName,
|
|
Kind: forms.KindSubRecord,
|
|
Family: forms.Dense,
|
|
Method: "POST",
|
|
Path: "/partials/operator/products/{productID}/prices",
|
|
// The whole composite re-renders: adding a price changes the prices
|
|
// table, so a refusal must swap into the same target the success does
|
|
// (design D9, lesson L§17). Single instance per page, so no
|
|
// Binding.Target override is needed.
|
|
Target: "#operator-body",
|
|
Swap: "innerHTML",
|
|
Fields: []forms.Field{
|
|
// A currency amount, not an integer count: Text with a decimal
|
|
// inputmode reads better than the Number control, whose Parse
|
|
// path assumes whole numbers (strconv.Atoi) and whose native
|
|
// spinner and locale handling are known rough edges for currency
|
|
// input. The handler still parses and range-checks the raw
|
|
// string itself (dollars to integer cents, Stripe's ceiling).
|
|
{
|
|
Name: "amount",
|
|
Label: "Amount",
|
|
Control: forms.Text,
|
|
Inputmode: "decimal",
|
|
// A money amount in a text control: shorter than the third a
|
|
// text field usually needs.
|
|
Width: forms.WidthNarrower,
|
|
},
|
|
{
|
|
Name: "currency",
|
|
Label: "Currency",
|
|
Control: forms.Select,
|
|
Options: []forms.Option{{Value: "usd", Label: "USD"}},
|
|
// A code, not a sentence: narrower than a select's third.
|
|
Width: forms.WidthNarrower,
|
|
},
|
|
// recurring_interval's three options are validated by Parse's own
|
|
// membership check, closing FA-11 (the control was `required` in
|
|
// markup with nothing behind it): "one_time" is the form's own
|
|
// value for a price with no recurrence, translated to a NULL
|
|
// column by the handler.
|
|
{
|
|
Name: "recurring_interval",
|
|
Label: "Interval",
|
|
Control: forms.Select,
|
|
Options: []forms.Option{
|
|
{Value: "month", Label: "Monthly"},
|
|
{Value: "year", Label: "Yearly"},
|
|
{Value: "one_time", Label: "One-time"},
|
|
},
|
|
// One word: narrower than a select's third.
|
|
Width: forms.WidthNarrower,
|
|
},
|
|
// Trial days is intentionally undeclared: trial_period_days is
|
|
// stored but not yet honored anywhere in the billing chain
|
|
// (checkout sets no trial), so collecting it would promise a
|
|
// trial members never get (finding #1, FA-6). CreatePrice no
|
|
// longer reads it at all; the column stays for when the payments
|
|
// model builds trials.
|
|
},
|
|
Commit: "Add price",
|
|
WayOut: forms.ClosePanel("Cancel", productPriceAddPanelID),
|
|
})
|
|
|
|
// productPriceFormView renders the price-add panel, bound to whatever was
|
|
// submitted (fresh, or a refusal).
|
|
func productPriceFormView(productID string, values forms.Values, errs *forms.Errors) forms.FormView {
|
|
mode := forms.ModeRecord
|
|
if errs.Any() {
|
|
mode = forms.ModeSubmission
|
|
}
|
|
action, err := web.RouteURL(productPriceForm.Path, productID)
|
|
if err != nil {
|
|
action = productPriceForm.Path
|
|
}
|
|
return forms.Render(productPriceForm, forms.Binding{Mode: mode, Action: action, Values: values, Errors: errs})
|
|
}
|