Skip to content

Commit 69f3fa1

Browse files
committed
feat: checkout attribution with wholesale pricing and auto-commission, SSO login flows with SCIM provisioning, reviews UI
Checkout attribution (Phase 7 completion): orders gain reseller_id/reseller_email/referral_code/affiliate_id snapshots (no FK - snapshots survive deletions); CheckoutByPlan accepts ?reseller_code=&ref= (+htc_ref cookie fallback). Wholesale pricing: buyer-email match on a reseller price override charges the override price; the cut becomes a one-time fixed-amount Stripe coupon (catalog 10000 / override 7500 -> 2500). Currency mismatch or override >= catalog falls back to catalog. Fulfilment auto-accrues reseller commissions (floor basis*bps/10000, idempotent per reseller+order) and auto-records affiliate conversions (idempotent per order, replay-safe on all recordOrder paths, never blocks fulfilment). SSO login + SCIM (Phase 8 slice 2): SAML AuthnRequest/ACS and OIDC code-flow endpoints issue normal portal sessions; real XML-DSig verification via goxmldsig against the pinned IdP cert (unsigned/forged assertions rejected), domain gate after verification, fail-closed Conditions window with clock skew, state/nonce binding, clamped return paths (no open redirect). SCIM 2.0 /Users CRUD behind htc_scim_ bearer middleware (identical 401s, no state oracle) mapping onto platform users via scim_identities (link-not-duplicate, DELETE = deactivate). Reviews UI: shared StarRating component (integer bps math, no floats), marketplace product reviews section + related-products rail + write/edit/delete own review (409 DUPLICATE flips to edit mode), rating badges on catalog cards, /admin/reviews moderation queue with approve/reject/reply/delete + 409 transition handling. i18n en+zh. Routes: /auth/sso/:id/start, /auth/sso/saml/acs, /auth/sso/oidc/callback (RateLimitByIPScoped sso_auth), /scim/v2/Users*. Migrations 20261008_{141000_checkout_attribution,142000_scim_identities}. New dep: github.com/russellhaering/goxmldsig. go test ./... green; web typecheck/lint/build green.
1 parent e371881 commit 69f3fa1

39 files changed

Lines changed: 8751 additions & 75 deletions

‎cmd/server/main.go‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -940,6 +940,33 @@ func main() {
940940
middleware.RateLimitByIPScoped("affiliate_convert", 30, time.Minute),
941941
affiliatePublicH.Convert)
942942

943+
// Enterprise SSO login (Phase 8 slice 2): SAML + OIDC handshakes
944+
// that land the user in a normal portal session. The domain gate +
945+
// signature verification live in the handler; identity is only
946+
// trusted from a verified assertion.
947+
ssoAuthH := handler.NewSSOAuthHandler(db, cfg)
948+
r.GET("/auth/sso/:id/start",
949+
middleware.RateLimitByIPScoped("sso_auth", cfg.RateLimitAuth, time.Minute),
950+
ssoAuthH.Start)
951+
r.POST("/auth/sso/saml/acs",
952+
middleware.RateLimitByIPScoped("sso_auth", cfg.RateLimitAuth, time.Minute),
953+
ssoAuthH.ACS)
954+
r.GET("/auth/sso/oidc/callback",
955+
middleware.RateLimitByIPScoped("sso_auth", cfg.RateLimitAuth, time.Minute),
956+
ssoAuthH.Callback)
957+
958+
// SCIM 2.0 provisioning: its own wire format (application/scim+json)
959+
// and its own auth (htc_scim_ bearer tokens). The middleware answers
960+
// identical 401s for missing/unknown/revoked tokens.
961+
scimH := handler.NewSCIMHandler(db, cfg.BaseURL)
962+
scim := r.Group("/scim/v2", middleware.SCIMTokenAuth(db))
963+
scim.GET("/Users", scimH.ListUsers)
964+
scim.POST("/Users", scimH.CreateUser)
965+
scim.GET("/Users/:id", scimH.GetUser)
966+
scim.PUT("/Users/:id", scimH.ReplaceUser)
967+
scim.PATCH("/Users/:id", scimH.PatchUser)
968+
scim.DELETE("/Users/:id", scimH.DeleteUser)
969+
943970
portal := v1.Group("/portal", middleware.SessionAuth(cfg.JWTSecret, db.FindUserIsAdmin))
944971
{
945972
portal.GET("/me", authH.Me)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- Reverse of the checkout attribution slice: the four snapshot
2+
-- columns go (the index drops with its column; the explicit drop is
3+
-- kept for clarity). No rows are rewritten and no partner ledger rows
4+
-- are touched — commissions and conversions carry their own snapshots
5+
-- and outlive this schema, by design.
6+
7+
DROP INDEX IF EXISTS idx_orders_reseller_id;
8+
9+
ALTER TABLE orders DROP COLUMN IF EXISTS reseller_id;
10+
ALTER TABLE orders DROP COLUMN IF EXISTS reseller_email;
11+
ALTER TABLE orders DROP COLUMN IF EXISTS referral_code;
12+
ALTER TABLE orders DROP COLUMN IF EXISTS affiliate_id;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
-- Checkout attribution (Phase 7, checkout slice): who brought each
2+
-- sale — the reseller the order is attributed to and the affiliate
3+
-- referral code (and its owning affiliate) it arrived through.
4+
--
5+
-- Everything here is additive and nullable. An order created before
6+
-- this migration — or written by a flow with no attribution (the
7+
-- normal public checkout) — keeps working with every new column NULL.
8+
-- The columns are nullable TEXT because "no partner brought this
9+
-- sale" is the normal state of most orders and NULL is the one
10+
-- representation of it (bun writes empty as NULL, the API serializes
11+
-- NULL away).
12+
--
13+
-- NO foreign keys, deliberately — the same doctrine as
14+
-- commissions.order_id and affiliate_conversions.order_id: these are
15+
-- snapshots in a commercial record and must survive the partner
16+
-- account being deleted (and any order-retention purge). The
17+
-- reseller_email column keeps the human-readable attribution exactly
18+
-- as it was at sale time for the same reason.
19+
--
20+
-- reseller_id is indexed: "this partner's orders" is the listing the
21+
-- reseller commission reconciliation reads.
22+
23+
ALTER TABLE orders ADD COLUMN IF NOT EXISTS reseller_id TEXT;
24+
ALTER TABLE orders ADD COLUMN IF NOT EXISTS reseller_email TEXT;
25+
ALTER TABLE orders ADD COLUMN IF NOT EXISTS referral_code TEXT;
26+
ALTER TABLE orders ADD COLUMN IF NOT EXISTS affiliate_id TEXT;
27+
28+
CREATE INDEX IF NOT EXISTS idx_orders_reseller_id ON orders (reseller_id);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- Drop the SCIM provisioning join. The index is dropped with the table.
2+
DROP TABLE IF EXISTS scim_identities;
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
-- Enterprise SSO + SCIM provisioning (Phase 8, slice 2): the mapping
2+
-- between SCIM 2.0 /Users resources and platform users.
3+
--
4+
-- Slice 1 created sso_connections + scim_tokens (the CONFIG and the
5+
-- bearer CREDENTIAL). This slice is the runtime: the SAML/OIDC login
6+
-- handshake and the SCIM /Users endpoints that CREATE, UPDATE and
7+
-- DEPROVISION platform users on behalf of an identity provider.
8+
--
9+
-- scim_identities is the join between the two worlds. A SCIM-managed
10+
-- user is an ordinary users row (it logs in through the normal portal
11+
-- session exactly like any other account) plus one scim_identities row
12+
-- that remembers "this user is provisioned from a directory" and holds
13+
-- the two facts that only the directory knows:
14+
--
15+
-- - external_id — the IdP's own identifier for the person (Okta's
16+
-- "externalId", Entra's). Nullable: not every provider sends one.
17+
-- NOT unique here — the same value may be minted by two different
18+
-- directories, and this table is not scoped to a connection (see
19+
-- the note on user_id below). Indexed because a client may filter
20+
-- on it.
21+
--
22+
-- - active — the SCIM "active" attribute. SCIM DELETE is a DEACTIVATE,
23+
-- not a row removal: the account is preserved (its licences, orders
24+
-- and audit trail stay intact) and this flag is what the SSO login
25+
-- path consults before letting the person in. A deactivated identity
26+
-- therefore blocks sign-in without destroying anything.
27+
--
28+
-- user_id is UNIQUE and a FK to users(id) ON DELETE CASCADE:
29+
--
30+
-- - UNIQUE because one platform user is provisioned at most once. The
31+
-- SCIM userName IS the user's email, and users.email is already
32+
-- unique — so "one identity per user" is the same cardinality rule,
33+
-- expressed as a database constraint so a retry that races past the
34+
-- handler's pre-check still cannot create a second row.
35+
--
36+
-- - CASCADE so deleting a platform user (admin hard-delete) takes its
37+
-- provisioning bookkeeping with it instead of orphaning a row no
38+
-- endpoint can ever resolve.
39+
--
40+
-- The rows here deliberately do NOT carry the user's name or email: those
41+
-- live on the users row (single source of truth). GivenName/FamilyName in
42+
-- the SCIM payload are derived from users.name on the way out and folded
43+
-- back into users.name on the way in (see handler/scim.go), so a name
44+
-- change via SCIM is a normal user-profile update, not a second copy that
45+
-- can drift.
46+
CREATE TABLE IF NOT EXISTS scim_identities (
47+
id TEXT PRIMARY KEY,
48+
-- The IdP's identifier for the user. Nullable; indexed for the
49+
-- externalId filter. Not unique — see the header note.
50+
external_id TEXT,
51+
-- The provisioned platform user. UNIQUE (one identity per user) and
52+
-- CASCADE on user deletion.
53+
user_id TEXT NOT NULL
54+
CONSTRAINT scim_identities_user_id_key UNIQUE
55+
REFERENCES users(id) ON DELETE CASCADE,
56+
-- SCIM "active". false = deactivated (SCIM DELETE); the SSO login
57+
-- path refuses a deactivated identity.
58+
active BOOLEAN NOT NULL DEFAULT true,
59+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
60+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
61+
);
62+
63+
-- A client may filter /Users by externalId; keep that lookup indexed.
64+
CREATE INDEX IF NOT EXISTS scim_identities_external_id_idx
65+
ON scim_identities (external_id);

‎go.mod‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ require (
3131
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
3232
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
3333
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
34+
github.com/beevik/etree v1.7.0 // indirect
3435
github.com/beorn7/perks v1.0.1 // indirect
3536
github.com/bytedance/gopkg v0.1.3 // indirect
3637
github.com/bytedance/sonic v1.15.0 // indirect
@@ -45,6 +46,7 @@ require (
4546
github.com/goccy/go-json v0.10.5 // indirect
4647
github.com/goccy/go-yaml v1.19.2 // indirect
4748
github.com/jinzhu/inflection v1.0.0 // indirect
49+
github.com/jonboulle/clockwork v0.5.0 // indirect
4850
github.com/json-iterator/go v1.1.12 // indirect
4951
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
5052
github.com/leodido/go-urn v1.4.0 // indirect
@@ -60,6 +62,7 @@ require (
6062
github.com/quic-go/qpack v0.6.0 // indirect
6163
github.com/quic-go/quic-go v0.59.1 // indirect
6264
github.com/rogpeppe/go-internal v1.14.1 // indirect
65+
github.com/russellhaering/goxmldsig v1.6.1 // indirect
6366
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
6467
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
6568
github.com/ugorji/go/codec v1.3.1 // indirect

‎go.sum‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lA
2222
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
2323
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
2424
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
25+
github.com/beevik/etree v1.7.0 h1:xjBk9O4p4x7D1YajePjfLzdaFC4/uYUENA7P0pv6gXA=
26+
github.com/beevik/etree v1.7.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc=
2527
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
2628
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
2729
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
@@ -70,6 +72,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
7072
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
7173
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
7274
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
75+
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
76+
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
7377
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
7478
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
7579
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@@ -114,6 +118,8 @@ github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq
114118
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
115119
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
116120
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
121+
github.com/russellhaering/goxmldsig v1.6.1 h1:SB7R5ttvrGIDB2juJAK/i7DQ2Ivr7agG+ohfNJjwyYU=
122+
github.com/russellhaering/goxmldsig v1.6.1/go.mod h1:haZkRcLs9W/Xp989fIjP3BrTdbFQveRF0QNZSYoH09w=
117123
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
118124
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
119125
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=

0 commit comments

Comments
 (0)