Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions internal/cli/customers.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -197,9 +198,14 @@ Confirmation: prompts under TTY; pass --yes to skip. Required under --no-input.`
return fmt.Errorf("decoding simulated purchase response: %w", err)
}
rt.Out.Success(fmt.Sprintf("Simulated purchase for %s", appUserID))
// The raw customer_info is the SDK (/receipts) shape — entitlements keyed
// by identifier, which differs from `customers show`. Surface the active
// entitlement identifiers directly so the purchase can be verified from
// this one command instead of switching to `customers show`.
return rt.Out.Render(map[string]any{
"app_id": appID, "app_user_id": appUserID, "product": *selected,
"fetch_token": fetchToken, "customer_info": customerInfo,
"active_entitlements": activeEntitlementIDs(raw),
})
},
}
Expand All @@ -210,6 +216,42 @@ Confirmation: prompts under TTY; pass --yes to skip. Required under --no-input.`
return cmd
}

// activeEntitlementIDs returns the identifiers of entitlements active in an SDK
// /receipts (customer_info) response — expiry in the future, or none (lifetime).
// It lets a simulated purchase be verified from this command instead of switching
// to `customers show` (which uses the different v2 shape). Best-effort: malformed
// or unparseable entries are skipped rather than reported as active.
func activeEntitlementIDs(raw json.RawMessage) []string {
var resp struct {
Subscriber struct {
Entitlements map[string]struct {
ExpiresDate *string `json:"expires_date"`
} `json:"entitlements"`
} `json:"subscriber"`
}
ids := []string{}
if err := json.Unmarshal(raw, &resp); err != nil {
return ids
}
now := time.Now()
for id, e := range resp.Subscriber.Entitlements {
if e.ExpiresDate == nil {
ids = append(ids, id) // non-expiring / lifetime
continue
}
for _, layout := range []string{time.RFC3339, time.RFC3339Nano} {
if t, err := time.Parse(layout, *e.ExpiresDate); err == nil {
if t.After(now) {
ids = append(ids, id)
}
break
}
}
}
sort.Strings(ids)
return ids
Comment thread
cursor[bot] marked this conversation as resolved.
}

func simulatedStoreFetchToken() (string, error) {
random := make([]byte, 16)
if _, err := rand.Read(random); err != nil {
Expand Down
44 changes: 44 additions & 0 deletions internal/cli/customers_entitlements_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package cli

import (
"encoding/json"
"strings"
"testing"
"time"
)

func TestActiveEntitlementIDs(t *testing.T) {
future := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)
past := time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339)

raw := json.RawMessage(`{
"subscriber": {
"entitlements": {
"premium": {"expires_date": "` + future + `"},
"expired": {"expires_date": "` + past + `"},
"lifetime": {"expires_date": null},
"garbage": {"expires_date": "not-a-date"}
}
}
}`)

got := activeEntitlementIDs(raw)
want := []string{"lifetime", "premium"} // sorted; expired + garbage excluded
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("activeEntitlementIDs = %v, want %v", got, want)
}
}

func TestActiveEntitlementIDs_Malformed(t *testing.T) {
// Non-object / missing subscriber → empty, never a panic, and must encode
// as a JSON array (`[]`) rather than `null` so `--json | jq` stays safe.
for _, in := range []string{`"just a string"`, `{}`, `{"subscriber":{}}`, `not json`} {
got := activeEntitlementIDs(json.RawMessage(in))
if len(got) != 0 {
t.Errorf("activeEntitlementIDs(%q) = %v, want empty", in, got)
}
if b, _ := json.Marshal(got); string(b) != "[]" {
t.Errorf("activeEntitlementIDs(%q) marshals to %s, want []", in, b)
}
}
}
Loading