Add merchant-scoped marketplace checkout plumbing
Some checks are pending
check / secretapi (push) Waiting to run
Some checks are pending
check / secretapi (push) Waiting to run
This commit is contained in:
parent
20e68c4dff
commit
b8d9147f5c
@ -397,7 +397,7 @@ func (a *app) handleMembershipQuote(w http.ResponseWriter, r *http.Request) {
|
||||
writeErrorCode(w, http.StatusForbidden, "sponsor_not_authorized", "sponsor entitlement is not active")
|
||||
return
|
||||
}
|
||||
hasWorkspaceCore, entErr := a.store.hasActiveEntitlement(r.Context(), payerAddress, offerIDWorkspaceCore, sponsorOrgRoot)
|
||||
hasWorkspaceCore, entErr := a.store.hasActiveEntitlement(r.Context(), payerAddress, defaultMarketplaceMerchantID, offerIDWorkspaceCore, sponsorOrgRoot)
|
||||
if entErr != nil {
|
||||
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to validate sponsor entitlement")
|
||||
return
|
||||
@ -1033,7 +1033,7 @@ func (a *app) handleGovernanceOfflineRenew(w http.ResponseWriter, r *http.Reques
|
||||
if isSoloRoot {
|
||||
requiredOfferID = offerIDSoloCore
|
||||
}
|
||||
hasRequired, entErr := a.store.hasActiveEntitlement(r.Context(), wallet, requiredOfferID, principal.OrgRootID)
|
||||
hasRequired, entErr := a.store.hasActiveEntitlement(r.Context(), wallet, defaultMarketplaceMerchantID, requiredOfferID, principal.OrgRootID)
|
||||
if entErr != nil {
|
||||
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve sovereign entitlement")
|
||||
return
|
||||
|
||||
@ -1090,6 +1090,9 @@ func TestMarketplaceCheckoutBundlesMembershipAndMintsEntitlement(t *testing.T) {
|
||||
if len(quote.LineItems) < 2 {
|
||||
t.Fatalf("expected license + membership line items: %+v", quote.LineItems)
|
||||
}
|
||||
if quote.MerchantID != defaultMarketplaceMerchantID {
|
||||
t.Fatalf("expected default merchant id, got %+v", quote)
|
||||
}
|
||||
|
||||
confirm := postJSONExpect[marketplaceCheckoutConfirmResponse](t, a, "/marketplace/checkout/confirm", marketplaceCheckoutConfirmRequest{
|
||||
QuoteID: quote.QuoteID,
|
||||
@ -1117,6 +1120,90 @@ func TestMarketplaceCheckoutBundlesMembershipAndMintsEntitlement(t *testing.T) {
|
||||
if entitlements.Entitlements[0].OfferID != offerIDWorkspaceCore {
|
||||
t.Fatalf("unexpected entitlement offer: %+v", entitlements.Entitlements[0])
|
||||
}
|
||||
if entitlements.Entitlements[0].MerchantID != defaultMarketplaceMerchantID {
|
||||
t.Fatalf("expected default merchant id on entitlement, got %+v", entitlements.Entitlements[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketplaceQuoteRejectsUnknownMerchant(t *testing.T) {
|
||||
a, _, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
ownerKey := mustKey(t)
|
||||
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
|
||||
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
|
||||
t.Fatalf("seed active membership: %v", err)
|
||||
}
|
||||
|
||||
errResp := postJSONExpect[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
||||
MerchantID: "acme.workspace",
|
||||
Wallet: ownerAddr,
|
||||
OfferID: offerIDWorkspaceCore,
|
||||
OrgRootID: "org.marketplace.acme",
|
||||
PrincipalID: "human.owner",
|
||||
PrincipalRole: "org_root_owner",
|
||||
}, http.StatusNotFound)
|
||||
if code := errResp["code"]; code != "offer_not_found" {
|
||||
t.Fatalf("expected offer_not_found for unknown merchant, got %+v", errResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketplaceEntitlementsFilterByMerchant(t *testing.T) {
|
||||
a, _, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
ownerKey := mustKey(t)
|
||||
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
|
||||
now := time.Now().UTC()
|
||||
if err := a.store.putMarketplaceEntitlement(context.Background(), marketplaceEntitlementRecord{
|
||||
EntitlementID: "ent:seed:firstparty",
|
||||
QuoteID: "seed-q-firstparty",
|
||||
MerchantID: defaultMarketplaceMerchantID,
|
||||
OfferID: offerIDWorkspaceCore,
|
||||
Wallet: ownerAddr,
|
||||
OrgRootID: "org.marketplace.filter",
|
||||
PrincipalID: "human.owner",
|
||||
PrincipalRole: "org_root_owner",
|
||||
State: "active",
|
||||
AccessClass: "connected",
|
||||
AvailabilityState: "active",
|
||||
PolicyHash: "sha256:testpolicy",
|
||||
IssuedAt: now,
|
||||
TxHash: "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed first-party entitlement: %v", err)
|
||||
}
|
||||
if err := a.store.putMarketplaceEntitlement(context.Background(), marketplaceEntitlementRecord{
|
||||
EntitlementID: "ent:seed:other",
|
||||
QuoteID: "seed-q-other",
|
||||
MerchantID: "acme.workspace",
|
||||
OfferID: "acme.offer.alpha",
|
||||
Wallet: ownerAddr,
|
||||
OrgRootID: "org.marketplace.filter",
|
||||
PrincipalID: "human.owner",
|
||||
PrincipalRole: "org_root_owner",
|
||||
State: "active",
|
||||
AccessClass: "connected",
|
||||
AvailabilityState: "active",
|
||||
PolicyHash: "sha256:testpolicy",
|
||||
IssuedAt: now,
|
||||
TxHash: "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed third-party entitlement: %v", err)
|
||||
}
|
||||
|
||||
all := getJSONExpect[marketplaceEntitlementsResponse](t, a, "/marketplace/entitlements?wallet="+ownerAddr, http.StatusOK)
|
||||
if len(all.Entitlements) != 2 {
|
||||
t.Fatalf("expected both merchant entitlements, got %+v", all.Entitlements)
|
||||
}
|
||||
|
||||
firstParty := getJSONExpect[marketplaceEntitlementsResponse](t, a, "/marketplace/entitlements?wallet="+ownerAddr+"&merchant_id="+defaultMarketplaceMerchantID, http.StatusOK)
|
||||
if len(firstParty.Entitlements) != 1 {
|
||||
t.Fatalf("expected one first-party entitlement, got %+v", firstParty.Entitlements)
|
||||
}
|
||||
if firstParty.Entitlements[0].MerchantID != defaultMarketplaceMerchantID {
|
||||
t.Fatalf("expected first-party merchant id, got %+v", firstParty.Entitlements[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketplaceDistinctPayerRequiresOwnershipProof(t *testing.T) {
|
||||
|
||||
@ -16,6 +16,7 @@ import (
|
||||
const (
|
||||
marketplaceMembershipActivationAtomic = "100000000" // 100.00 USDC (6 decimals)
|
||||
marketplaceStandardOfferAtomic = "1000000000" // 1000.00 USDC (6 decimals)
|
||||
defaultMarketplaceMerchantID = "edut.firstparty"
|
||||
|
||||
offerIDSoloCore = "edut.solo.core"
|
||||
offerIDWorkspaceCore = "edut.workspace.core"
|
||||
@ -24,11 +25,33 @@ const (
|
||||
offerIDWorkspaceSovereign = "edut.workspace.sovereign"
|
||||
)
|
||||
|
||||
func (a *app) marketplaceOffers() []marketplaceOffer {
|
||||
func normalizeMerchantID(raw string) string {
|
||||
merchantID := strings.ToLower(strings.TrimSpace(raw))
|
||||
if merchantID == "" {
|
||||
return defaultMarketplaceMerchantID
|
||||
}
|
||||
return merchantID
|
||||
}
|
||||
|
||||
func marketplaceContractOfferID(merchantID, offerID string) string {
|
||||
merchantID = normalizeMerchantID(merchantID)
|
||||
offerID = strings.TrimSpace(strings.ToLower(offerID))
|
||||
if merchantID == defaultMarketplaceMerchantID {
|
||||
return offerID
|
||||
}
|
||||
return merchantID + ":" + offerID
|
||||
}
|
||||
|
||||
func (a *app) marketplaceOffersForMerchant(merchantID string) []marketplaceOffer {
|
||||
merchantID = normalizeMerchantID(merchantID)
|
||||
if merchantID != defaultMarketplaceMerchantID {
|
||||
return []marketplaceOffer{}
|
||||
}
|
||||
offers := []marketplaceOffer{
|
||||
{
|
||||
MerchantID: defaultMarketplaceMerchantID,
|
||||
OfferID: offerIDSoloCore,
|
||||
IssuerID: "edut.firstparty",
|
||||
IssuerID: defaultMarketplaceMerchantID,
|
||||
Title: "EDUT Solo Core",
|
||||
Summary: "Single-principal governance runtime for personal operations.",
|
||||
Status: "active",
|
||||
@ -54,8 +77,9 @@ func (a *app) marketplaceOffers() []marketplaceOffer {
|
||||
SortOrder: 10,
|
||||
},
|
||||
{
|
||||
MerchantID: defaultMarketplaceMerchantID,
|
||||
OfferID: offerIDWorkspaceCore,
|
||||
IssuerID: "edut.firstparty",
|
||||
IssuerID: defaultMarketplaceMerchantID,
|
||||
Title: "EDUT Workspace Core",
|
||||
Summary: "Org-bound deterministic governance runtime for team operations.",
|
||||
Status: "active",
|
||||
@ -81,8 +105,9 @@ func (a *app) marketplaceOffers() []marketplaceOffer {
|
||||
SortOrder: 20,
|
||||
},
|
||||
{
|
||||
MerchantID: defaultMarketplaceMerchantID,
|
||||
OfferID: offerIDWorkspaceAI,
|
||||
IssuerID: "edut.firstparty",
|
||||
IssuerID: defaultMarketplaceMerchantID,
|
||||
Title: "EDUT Workspace AI Layer",
|
||||
Summary: "AI reasoning layer for governed workspace operations.",
|
||||
Status: "active",
|
||||
@ -109,8 +134,9 @@ func (a *app) marketplaceOffers() []marketplaceOffer {
|
||||
SortOrder: 30,
|
||||
},
|
||||
{
|
||||
MerchantID: defaultMarketplaceMerchantID,
|
||||
OfferID: offerIDWorkspaceLane24,
|
||||
IssuerID: "edut.firstparty",
|
||||
IssuerID: defaultMarketplaceMerchantID,
|
||||
Title: "EDUT Workspace 24h Lane",
|
||||
Summary: "Autonomous execution lane capacity for workspace queue throughput.",
|
||||
Status: "active",
|
||||
@ -136,8 +162,9 @@ func (a *app) marketplaceOffers() []marketplaceOffer {
|
||||
SortOrder: 40,
|
||||
},
|
||||
{
|
||||
MerchantID: defaultMarketplaceMerchantID,
|
||||
OfferID: offerIDWorkspaceSovereign,
|
||||
IssuerID: "edut.firstparty",
|
||||
IssuerID: defaultMarketplaceMerchantID,
|
||||
Title: "EDUT Workspace Sovereign Continuity",
|
||||
Summary: "Workspace continuity profile for stronger local/offline operation.",
|
||||
Status: "active",
|
||||
@ -169,9 +196,13 @@ func (a *app) marketplaceOffers() []marketplaceOffer {
|
||||
return offers
|
||||
}
|
||||
|
||||
func (a *app) marketplaceOfferByID(offerID string) (marketplaceOffer, error) {
|
||||
func (a *app) marketplaceOffers() []marketplaceOffer {
|
||||
return a.marketplaceOffersForMerchant(defaultMarketplaceMerchantID)
|
||||
}
|
||||
|
||||
func (a *app) marketplaceOfferByID(merchantID, offerID string) (marketplaceOffer, error) {
|
||||
target := strings.TrimSpace(strings.ToLower(offerID))
|
||||
for _, offer := range a.marketplaceOffers() {
|
||||
for _, offer := range a.marketplaceOffersForMerchant(merchantID) {
|
||||
if strings.EqualFold(strings.TrimSpace(offer.OfferID), target) {
|
||||
return offer, nil
|
||||
}
|
||||
@ -210,7 +241,8 @@ func (a *app) handleMarketplaceOffers(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, marketplaceOffersResponse{Offers: a.marketplaceOffers()})
|
||||
merchantID := normalizeMerchantID(r.URL.Query().Get("merchant_id"))
|
||||
writeJSON(w, http.StatusOK, marketplaceOffersResponse{Offers: a.marketplaceOffersForMerchant(merchantID)})
|
||||
}
|
||||
|
||||
func (a *app) handleMarketplaceOfferByID(w http.ResponseWriter, r *http.Request) {
|
||||
@ -224,7 +256,8 @@ func (a *app) handleMarketplaceOfferByID(w http.ResponseWriter, r *http.Request)
|
||||
writeErrorCode(w, http.StatusNotFound, "offer_not_found", "offer not found")
|
||||
return
|
||||
}
|
||||
offer, err := a.marketplaceOfferByID(offerID)
|
||||
merchantID := normalizeMerchantID(r.URL.Query().Get("merchant_id"))
|
||||
offer, err := a.marketplaceOfferByID(merchantID, offerID)
|
||||
if err != nil {
|
||||
writeErrorCode(w, http.StatusNotFound, "offer_not_found", "offer not found")
|
||||
return
|
||||
@ -250,7 +283,8 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
|
||||
if !a.enforceWalletSession(w, r, wallet) {
|
||||
return
|
||||
}
|
||||
offer, err := a.marketplaceOfferByID(req.OfferID)
|
||||
merchantID := normalizeMerchantID(req.MerchantID)
|
||||
offer, err := a.marketplaceOfferByID(merchantID, req.OfferID)
|
||||
if err != nil {
|
||||
writeErrorCode(w, http.StatusNotFound, "offer_not_found", "offer not found")
|
||||
return
|
||||
@ -313,7 +347,7 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
for _, requiredOfferID := range offer.Policies.RequiresOffers {
|
||||
hasRequired, err := a.store.hasActiveEntitlement(r.Context(), wallet, requiredOfferID, orgRootID)
|
||||
hasRequired, err := a.store.hasActiveEntitlement(r.Context(), wallet, merchantID, requiredOfferID, orgRootID)
|
||||
if err != nil {
|
||||
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve prerequisite entitlements")
|
||||
return
|
||||
@ -410,7 +444,7 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
txTo := entitlementContract
|
||||
txValueHex := "0x0"
|
||||
txData, calldataErr := encodePurchaseEntitlementCalldata(offer.OfferID, wallet, orgRootID, workspaceID)
|
||||
txData, calldataErr := encodePurchaseEntitlementCalldata(marketplaceContractOfferID(merchantID, offer.OfferID), wallet, orgRootID, workspaceID)
|
||||
if calldataErr != nil {
|
||||
writeErrorCode(w, http.StatusInternalServerError, "quote_generation_failed", "failed to encode checkout transaction")
|
||||
return
|
||||
@ -424,6 +458,7 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
quote := marketplaceQuoteRecord{
|
||||
QuoteID: "cq_" + quoteIDRaw,
|
||||
MerchantID: merchantID,
|
||||
Wallet: wallet,
|
||||
PayerWallet: payerWallet,
|
||||
OfferID: offer.OfferID,
|
||||
@ -453,6 +488,7 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
writeJSON(w, http.StatusOK, marketplaceCheckoutQuoteResponse{
|
||||
QuoteID: quote.QuoteID,
|
||||
MerchantID: quote.MerchantID,
|
||||
Wallet: quote.Wallet,
|
||||
PayerWallet: quote.PayerWallet,
|
||||
OfferID: quote.OfferID,
|
||||
@ -527,6 +563,11 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re
|
||||
writeErrorCode(w, http.StatusConflict, "quote_expired", "quote expired")
|
||||
return
|
||||
}
|
||||
merchantID := normalizeMerchantID(req.MerchantID)
|
||||
if !strings.EqualFold(quote.MerchantID, merchantID) {
|
||||
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "merchant_id mismatch")
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(quote.Wallet, wallet) ||
|
||||
!strings.EqualFold(strings.TrimSpace(quote.OfferID), strings.TrimSpace(req.OfferID)) {
|
||||
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "quote context mismatch")
|
||||
@ -566,6 +607,7 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re
|
||||
writeJSON(w, http.StatusOK, marketplaceCheckoutConfirmResponse{
|
||||
Status: "entitlement_active",
|
||||
EntitlementID: existing.EntitlementID,
|
||||
MerchantID: existing.MerchantID,
|
||||
OfferID: existing.OfferID,
|
||||
OrgRootID: existing.OrgRootID,
|
||||
PrincipalID: existing.PrincipalID,
|
||||
@ -633,6 +675,7 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re
|
||||
ent := marketplaceEntitlementRecord{
|
||||
EntitlementID: entitlementID,
|
||||
QuoteID: quote.QuoteID,
|
||||
MerchantID: quote.MerchantID,
|
||||
OfferID: quote.OfferID,
|
||||
Wallet: wallet,
|
||||
PayerWallet: quote.PayerWallet,
|
||||
@ -667,6 +710,7 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re
|
||||
writeJSON(w, http.StatusOK, marketplaceCheckoutConfirmResponse{
|
||||
Status: "entitlement_active",
|
||||
EntitlementID: ent.EntitlementID,
|
||||
MerchantID: ent.MerchantID,
|
||||
OfferID: ent.OfferID,
|
||||
OrgRootID: ent.OrgRootID,
|
||||
PrincipalID: ent.PrincipalID,
|
||||
@ -693,7 +737,8 @@ func (a *app) handleMarketplaceEntitlements(w http.ResponseWriter, r *http.Reque
|
||||
if !a.enforceWalletSession(w, r, wallet) {
|
||||
return
|
||||
}
|
||||
records, err := a.store.listMarketplaceEntitlementsByWallet(r.Context(), wallet)
|
||||
merchantID := strings.TrimSpace(r.URL.Query().Get("merchant_id"))
|
||||
records, err := a.store.listMarketplaceEntitlementsByWallet(r.Context(), wallet, merchantID)
|
||||
if err != nil {
|
||||
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to list entitlements")
|
||||
return
|
||||
@ -702,6 +747,7 @@ func (a *app) handleMarketplaceEntitlements(w http.ResponseWriter, r *http.Reque
|
||||
for _, rec := range records {
|
||||
out = append(out, marketplaceEntitlement{
|
||||
EntitlementID: rec.EntitlementID,
|
||||
MerchantID: rec.MerchantID,
|
||||
OfferID: rec.OfferID,
|
||||
WalletAddress: rec.Wallet,
|
||||
WorkspaceID: rec.WorkspaceID,
|
||||
|
||||
@ -3,6 +3,7 @@ package main
|
||||
import "time"
|
||||
|
||||
type marketplaceOffer struct {
|
||||
MerchantID string `json:"merchant_id,omitempty"`
|
||||
OfferID string `json:"offer_id"`
|
||||
IssuerID string `json:"issuer_id"`
|
||||
Title string `json:"title"`
|
||||
@ -42,6 +43,7 @@ type marketplaceOffersResponse struct {
|
||||
}
|
||||
|
||||
type marketplaceCheckoutQuoteRequest struct {
|
||||
MerchantID string `json:"merchant_id,omitempty"`
|
||||
Wallet string `json:"wallet"`
|
||||
PayerWallet string `json:"payer_wallet,omitempty"`
|
||||
OfferID string `json:"offer_id"`
|
||||
@ -64,6 +66,7 @@ type marketplaceQuoteLineItem struct {
|
||||
|
||||
type marketplaceCheckoutQuoteResponse struct {
|
||||
QuoteID string `json:"quote_id"`
|
||||
MerchantID string `json:"merchant_id,omitempty"`
|
||||
Wallet string `json:"wallet"`
|
||||
PayerWallet string `json:"payer_wallet,omitempty"`
|
||||
OfferID string `json:"offer_id"`
|
||||
@ -87,6 +90,7 @@ type marketplaceCheckoutQuoteResponse struct {
|
||||
|
||||
type marketplaceCheckoutConfirmRequest struct {
|
||||
QuoteID string `json:"quote_id"`
|
||||
MerchantID string `json:"merchant_id,omitempty"`
|
||||
Wallet string `json:"wallet"`
|
||||
PayerWallet string `json:"payer_wallet,omitempty"`
|
||||
OfferID string `json:"offer_id"`
|
||||
@ -101,6 +105,7 @@ type marketplaceCheckoutConfirmRequest struct {
|
||||
type marketplaceCheckoutConfirmResponse struct {
|
||||
Status string `json:"status"`
|
||||
EntitlementID string `json:"entitlement_id"`
|
||||
MerchantID string `json:"merchant_id,omitempty"`
|
||||
OfferID string `json:"offer_id"`
|
||||
OrgRootID string `json:"org_root_id,omitempty"`
|
||||
PrincipalID string `json:"principal_id,omitempty"`
|
||||
@ -115,6 +120,7 @@ type marketplaceCheckoutConfirmResponse struct {
|
||||
|
||||
type marketplaceEntitlement struct {
|
||||
EntitlementID string `json:"entitlement_id"`
|
||||
MerchantID string `json:"merchant_id,omitempty"`
|
||||
OfferID string `json:"offer_id"`
|
||||
WalletAddress string `json:"wallet_address"`
|
||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||
@ -132,6 +138,7 @@ type marketplaceEntitlementsResponse struct {
|
||||
|
||||
type marketplaceQuoteRecord struct {
|
||||
QuoteID string
|
||||
MerchantID string
|
||||
Wallet string
|
||||
PayerWallet string
|
||||
OfferID string
|
||||
@ -160,6 +167,7 @@ type marketplaceQuoteRecord struct {
|
||||
type marketplaceEntitlementRecord struct {
|
||||
EntitlementID string
|
||||
QuoteID string
|
||||
MerchantID string
|
||||
OfferID string
|
||||
Wallet string
|
||||
PayerWallet string
|
||||
|
||||
@ -99,6 +99,7 @@ func (s *store) migrate(ctx context.Context) error {
|
||||
`CREATE INDEX IF NOT EXISTS idx_quotes_confirmed_tx_hash ON quotes(confirmed_tx_hash);`,
|
||||
`CREATE TABLE IF NOT EXISTS marketplace_quotes (
|
||||
quote_id TEXT PRIMARY KEY,
|
||||
merchant_id TEXT NOT NULL DEFAULT 'edut.firstparty',
|
||||
wallet TEXT NOT NULL,
|
||||
payer_wallet TEXT,
|
||||
offer_id TEXT NOT NULL,
|
||||
@ -128,6 +129,7 @@ func (s *store) migrate(ctx context.Context) error {
|
||||
`CREATE TABLE IF NOT EXISTS marketplace_entitlements (
|
||||
entitlement_id TEXT PRIMARY KEY,
|
||||
quote_id TEXT NOT NULL UNIQUE,
|
||||
merchant_id TEXT NOT NULL DEFAULT 'edut.firstparty',
|
||||
offer_id TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
payer_wallet TEXT,
|
||||
@ -143,6 +145,7 @@ func (s *store) migrate(ctx context.Context) error {
|
||||
tx_hash TEXT NOT NULL
|
||||
);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_marketplace_entitlements_wallet ON marketplace_entitlements(wallet);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_marketplace_entitlements_wallet_merchant_offer_state ON marketplace_entitlements(wallet, merchant_id, offer_id, state);`,
|
||||
`CREATE TABLE IF NOT EXISTS governance_principals (
|
||||
wallet TEXT PRIMARY KEY,
|
||||
org_root_id TEXT NOT NULL,
|
||||
@ -282,6 +285,12 @@ func (s *store) migrate(ctx context.Context) error {
|
||||
if err := s.ensureColumn(ctx, "marketplace_quotes", "expected_tx_value_hex", "TEXT"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ensureColumn(ctx, "marketplace_quotes", "merchant_id", "TEXT NOT NULL DEFAULT 'edut.firstparty'"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ensureColumn(ctx, "marketplace_entitlements", "merchant_id", "TEXT NOT NULL DEFAULT 'edut.firstparty'"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -588,11 +597,12 @@ func (s *store) getDesignationCodeByMembershipTxHash(ctx context.Context, txHash
|
||||
func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteRecord) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO marketplace_quotes (
|
||||
quote_id, wallet, payer_wallet, offer_id, org_root_id, principal_id, principal_role, workspace_id,
|
||||
quote_id, merchant_id, wallet, payer_wallet, offer_id, org_root_id, principal_id, principal_role, workspace_id,
|
||||
currency, amount_atomic, total_amount_atomic, decimals, membership_included, line_items_json,
|
||||
policy_hash, access_class, availability_state, expected_tx_to, expected_tx_data, expected_tx_value_hex, created_at, expires_at, confirmed_at, confirmed_tx_hash
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(quote_id) DO UPDATE SET
|
||||
merchant_id=excluded.merchant_id,
|
||||
wallet=excluded.wallet,
|
||||
payer_wallet=excluded.payer_wallet,
|
||||
offer_id=excluded.offer_id,
|
||||
@ -617,6 +627,7 @@ func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteR
|
||||
confirmed_at=excluded.confirmed_at,
|
||||
confirmed_tx_hash=excluded.confirmed_tx_hash
|
||||
`, quote.QuoteID,
|
||||
normalizeMerchantID(quote.MerchantID),
|
||||
strings.ToLower(strings.TrimSpace(quote.Wallet)),
|
||||
nullableString(strings.ToLower(strings.TrimSpace(quote.PayerWallet))),
|
||||
strings.TrimSpace(quote.OfferID),
|
||||
@ -646,19 +657,20 @@ func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteR
|
||||
|
||||
func (s *store) getMarketplaceQuote(ctx context.Context, quoteID string) (marketplaceQuoteRecord, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT quote_id, wallet, payer_wallet, offer_id, org_root_id, principal_id, principal_role, workspace_id,
|
||||
SELECT quote_id, merchant_id, wallet, payer_wallet, offer_id, org_root_id, principal_id, principal_role, workspace_id,
|
||||
currency, amount_atomic, total_amount_atomic, decimals, membership_included, line_items_json,
|
||||
policy_hash, access_class, availability_state, expected_tx_to, expected_tx_data, expected_tx_value_hex, created_at, expires_at, confirmed_at, confirmed_tx_hash
|
||||
FROM marketplace_quotes
|
||||
WHERE quote_id = ?
|
||||
`, strings.TrimSpace(quoteID))
|
||||
var rec marketplaceQuoteRecord
|
||||
var payerWallet, orgRootID, principalID, principalRole, workspaceID sql.NullString
|
||||
var merchantID, payerWallet, orgRootID, principalID, principalRole, workspaceID sql.NullString
|
||||
var expectedTxTo, expectedTxData, expectedTxValueHex sql.NullString
|
||||
var createdAt, expiresAt, confirmedAt, confirmedTxHash sql.NullString
|
||||
var membershipIncluded int
|
||||
err := row.Scan(
|
||||
&rec.QuoteID,
|
||||
&merchantID,
|
||||
&rec.Wallet,
|
||||
&payerWallet,
|
||||
&rec.OfferID,
|
||||
@ -689,6 +701,7 @@ func (s *store) getMarketplaceQuote(ctx context.Context, quoteID string) (market
|
||||
}
|
||||
return marketplaceQuoteRecord{}, err
|
||||
}
|
||||
rec.MerchantID = normalizeMerchantID(merchantID.String)
|
||||
rec.PayerWallet = payerWallet.String
|
||||
rec.OrgRootID = orgRootID.String
|
||||
rec.PrincipalID = principalID.String
|
||||
@ -726,11 +739,12 @@ func (s *store) getMarketplaceQuoteIDByConfirmedTxHash(ctx context.Context, txHa
|
||||
func (s *store) putMarketplaceEntitlement(ctx context.Context, ent marketplaceEntitlementRecord) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO marketplace_entitlements (
|
||||
entitlement_id, quote_id, offer_id, wallet, payer_wallet, org_root_id, principal_id, principal_role,
|
||||
entitlement_id, quote_id, merchant_id, offer_id, wallet, payer_wallet, org_root_id, principal_id, principal_role,
|
||||
workspace_id, state, access_class, availability_state, policy_hash, issued_at, tx_hash
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(entitlement_id) DO UPDATE SET
|
||||
quote_id=excluded.quote_id,
|
||||
merchant_id=excluded.merchant_id,
|
||||
offer_id=excluded.offer_id,
|
||||
wallet=excluded.wallet,
|
||||
payer_wallet=excluded.payer_wallet,
|
||||
@ -746,6 +760,7 @@ func (s *store) putMarketplaceEntitlement(ctx context.Context, ent marketplaceEn
|
||||
tx_hash=excluded.tx_hash
|
||||
`, strings.TrimSpace(ent.EntitlementID),
|
||||
strings.TrimSpace(ent.QuoteID),
|
||||
normalizeMerchantID(ent.MerchantID),
|
||||
strings.TrimSpace(ent.OfferID),
|
||||
strings.ToLower(strings.TrimSpace(ent.Wallet)),
|
||||
nullableString(strings.ToLower(strings.TrimSpace(ent.PayerWallet))),
|
||||
@ -765,7 +780,7 @@ func (s *store) putMarketplaceEntitlement(ctx context.Context, ent marketplaceEn
|
||||
|
||||
func (s *store) getMarketplaceEntitlementByQuote(ctx context.Context, quoteID string) (marketplaceEntitlementRecord, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT entitlement_id, quote_id, offer_id, wallet, payer_wallet, org_root_id, principal_id, principal_role,
|
||||
SELECT entitlement_id, quote_id, merchant_id, offer_id, wallet, payer_wallet, org_root_id, principal_id, principal_role,
|
||||
workspace_id, state, access_class, availability_state, policy_hash, issued_at, tx_hash
|
||||
FROM marketplace_entitlements
|
||||
WHERE quote_id = ?
|
||||
@ -773,14 +788,20 @@ func (s *store) getMarketplaceEntitlementByQuote(ctx context.Context, quoteID st
|
||||
return scanMarketplaceEntitlement(row)
|
||||
}
|
||||
|
||||
func (s *store) listMarketplaceEntitlementsByWallet(ctx context.Context, wallet string) ([]marketplaceEntitlementRecord, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT entitlement_id, quote_id, offer_id, wallet, payer_wallet, org_root_id, principal_id, principal_role,
|
||||
func (s *store) listMarketplaceEntitlementsByWallet(ctx context.Context, wallet, merchantID string) ([]marketplaceEntitlementRecord, error) {
|
||||
query := `
|
||||
SELECT entitlement_id, quote_id, merchant_id, offer_id, wallet, payer_wallet, org_root_id, principal_id, principal_role,
|
||||
workspace_id, state, access_class, availability_state, policy_hash, issued_at, tx_hash
|
||||
FROM marketplace_entitlements
|
||||
WHERE wallet = ?
|
||||
ORDER BY issued_at DESC
|
||||
`, strings.ToLower(strings.TrimSpace(wallet)))
|
||||
`
|
||||
args := []any{strings.ToLower(strings.TrimSpace(wallet))}
|
||||
if strings.TrimSpace(merchantID) != "" {
|
||||
query += " AND merchant_id = ?"
|
||||
args = append(args, normalizeMerchantID(merchantID))
|
||||
}
|
||||
query += " ORDER BY issued_at DESC"
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -800,8 +821,9 @@ func (s *store) listMarketplaceEntitlementsByWallet(ctx context.Context, wallet
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (s *store) hasActiveEntitlement(ctx context.Context, wallet, offerID, orgRootID string) (bool, error) {
|
||||
func (s *store) hasActiveEntitlement(ctx context.Context, wallet, merchantID, offerID, orgRootID string) (bool, error) {
|
||||
wallet = strings.ToLower(strings.TrimSpace(wallet))
|
||||
merchantID = normalizeMerchantID(merchantID)
|
||||
offerID = strings.TrimSpace(offerID)
|
||||
orgRootID = strings.TrimSpace(orgRootID)
|
||||
if wallet == "" || offerID == "" {
|
||||
@ -812,10 +834,11 @@ func (s *store) hasActiveEntitlement(ctx context.Context, wallet, offerID, orgRo
|
||||
SELECT 1
|
||||
FROM marketplace_entitlements
|
||||
WHERE wallet = ?
|
||||
AND merchant_id = ?
|
||||
AND offer_id = ?
|
||||
AND state = 'active'
|
||||
`
|
||||
args := []any{wallet, offerID}
|
||||
args := []any{wallet, merchantID, offerID}
|
||||
if orgRootID != "" {
|
||||
query += " AND org_root_id = ?"
|
||||
args = append(args, orgRootID)
|
||||
@ -835,11 +858,12 @@ func (s *store) hasActiveEntitlement(ctx context.Context, wallet, offerID, orgRo
|
||||
|
||||
func scanMarketplaceEntitlement(row interface{ Scan(dest ...any) error }) (marketplaceEntitlementRecord, error) {
|
||||
var rec marketplaceEntitlementRecord
|
||||
var payerWallet, orgRootID, principalID, principalRole, workspaceID sql.NullString
|
||||
var merchantID, payerWallet, orgRootID, principalID, principalRole, workspaceID sql.NullString
|
||||
var issuedAt sql.NullString
|
||||
err := row.Scan(
|
||||
&rec.EntitlementID,
|
||||
&rec.QuoteID,
|
||||
&merchantID,
|
||||
&rec.OfferID,
|
||||
&rec.Wallet,
|
||||
&payerWallet,
|
||||
@ -860,6 +884,7 @@ func scanMarketplaceEntitlement(row interface{ Scan(dest ...any) error }) (marke
|
||||
}
|
||||
return marketplaceEntitlementRecord{}, err
|
||||
}
|
||||
rec.MerchantID = normalizeMerchantID(merchantID.String)
|
||||
rec.PayerWallet = payerWallet.String
|
||||
rec.OrgRootID = orgRootID.String
|
||||
rec.PrincipalID = principalID.String
|
||||
|
||||
@ -11,6 +11,13 @@ paths:
|
||||
/marketplace/offers:
|
||||
get:
|
||||
summary: List active offers (launcher/app surface)
|
||||
parameters:
|
||||
- in: query
|
||||
name: merchant_id
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Merchant namespace. Defaults to `edut.firstparty`.
|
||||
responses:
|
||||
'200':
|
||||
description: Offer list
|
||||
@ -33,6 +40,12 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: merchant_id
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Merchant namespace. Defaults to `edut.firstparty`.
|
||||
responses:
|
||||
'200':
|
||||
description: Offer record
|
||||
@ -86,6 +99,12 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
pattern: '^0x[a-fA-F0-9]{40}$'
|
||||
- in: query
|
||||
name: merchant_id
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Optional merchant filter. If omitted, returns all merchants for wallet.
|
||||
responses:
|
||||
'200':
|
||||
description: Entitlements list
|
||||
@ -113,6 +132,8 @@ components:
|
||||
type: object
|
||||
required: [offer_id, issuer_id, title, status, pricing, policies]
|
||||
properties:
|
||||
merchant_id:
|
||||
type: string
|
||||
offer_id:
|
||||
type: string
|
||||
issuer_id:
|
||||
@ -168,6 +189,9 @@ components:
|
||||
type: object
|
||||
required: [wallet, offer_id]
|
||||
properties:
|
||||
merchant_id:
|
||||
type: string
|
||||
description: Merchant namespace. Defaults to `edut.firstparty`.
|
||||
wallet:
|
||||
type: string
|
||||
pattern: '^0x[a-fA-F0-9]{40}$'
|
||||
@ -202,6 +226,8 @@ components:
|
||||
properties:
|
||||
quote_id:
|
||||
type: string
|
||||
merchant_id:
|
||||
type: string
|
||||
wallet:
|
||||
type: string
|
||||
payer_wallet:
|
||||
@ -272,6 +298,9 @@ components:
|
||||
properties:
|
||||
quote_id:
|
||||
type: string
|
||||
merchant_id:
|
||||
type: string
|
||||
description: Merchant namespace. Defaults to `edut.firstparty`.
|
||||
wallet:
|
||||
type: string
|
||||
pattern: '^0x[a-fA-F0-9]{40}$'
|
||||
@ -305,6 +334,8 @@ components:
|
||||
enum: [entitlement_active]
|
||||
entitlement_id:
|
||||
type: string
|
||||
merchant_id:
|
||||
type: string
|
||||
offer_id:
|
||||
type: string
|
||||
org_root_id:
|
||||
@ -335,6 +366,8 @@ components:
|
||||
properties:
|
||||
entitlement_id:
|
||||
type: string
|
||||
merchant_id:
|
||||
type: string
|
||||
offer_id:
|
||||
type: string
|
||||
wallet_address:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user