wiq-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wiq
4
+ module Commands
5
+ class Charges < Base
6
+ # Match Charge.statuses in app/models/charge.rb. Values are
7
+ # integer-backed via Rails enum (`enum status: { successful: 0, failed: 1 }`).
8
+ # Ransack 4.x does NOT auto-translate enum strings on integer columns,
9
+ # so the CLI translates here before sending q[status_eq]. Without this
10
+ # translation Ransack silently drops the predicate and returns every
11
+ # row — the kind of failure that's worse than a 422.
12
+ STATUS_TO_INT = { "successful" => 0, "failed" => 1 }.freeze
13
+ STATUSES = STATUS_TO_INT.keys.freeze
14
+ DEFAULT_PER_PAGE = 50
15
+
16
+ desc "list", "List charges (finance: admin coach only)"
17
+ long_desc <<~DESC
18
+ Returns charges (payment attempts) across the team. Useful for
19
+ answering "did this family pay X?" or "what's been failing
20
+ lately?"
21
+
22
+ Auth: admin-only (Pundit scope filters non-admin coach PATs to
23
+ empty results, parent/wrestler PATs return 403).
24
+
25
+ Filters translate to Ransack server-side:
26
+ --status successful | failed (enum is two-valued)
27
+ --billing-profile <id> q[billing_profile_id_eq] — the
28
+ parent/family who paid. Discover via
29
+ `wiq billing_profiles show <profile_id>
30
+ --profile-type ParentProfile`.
31
+ --chargeable-type <str> q[chargeable_type_eq] — the kind of
32
+ thing paid for. Common values:
33
+ BillingSubscriptionInvoice, Registration,
34
+ Donation, FundraiserContribution,
35
+ OnlineStoreOrder. (Verify against the
36
+ actual data — these are model class
37
+ names, not strings the API documents.)
38
+ --since <YYYY-MM-DD> q[created_at_gteq]
39
+ --until <YYYY-MM-DD> q[created_at_lteq]
40
+
41
+ Each row is rich — billing_profile.billable (the person paying),
42
+ chargeable (what they paid for), wrestlers[] (the kids the charge
43
+ is associated with), refunds, payout, coupon, category. Default
44
+ page size is 50 since charges accumulate fast; use --all for
45
+ exhaustive scans (paired with --since to bound the window).
46
+
47
+ **Critical caveat for failed-payment analysis.** A `failed` charge
48
+ is NOT actionable on its own — Stripe/Justifi retry subscriptions
49
+ automatically, and customers retry registrations after card
50
+ declines. Always cross-check that no `successful` charge exists
51
+ for the same (billing_profile_id, chargeable_id, chargeable_type)
52
+ tuple AFTER the failure timestamp. See `wiq workflows show
53
+ failed-payments-recent` for the canonical pattern.
54
+ DESC
55
+ method_option :status, type: :string, enum: STATUSES,
56
+ desc: "Filter to successful or failed charges"
57
+ method_option :billing_profile, type: :numeric,
58
+ desc: "Restrict to one billing_profile id"
59
+ method_option :chargeable_type, type: :string,
60
+ desc: "Filter by chargeable model name (e.g. BillingSubscriptionInvoice)"
61
+ method_option :since, type: :string, desc: "Earliest created_at (YYYY-MM-DD)"
62
+ method_option :until, type: :string, desc: "Latest created_at (YYYY-MM-DD)"
63
+ method_option :per_page, type: :numeric, default: DEFAULT_PER_PAGE,
64
+ desc: "Page size (default 50)"
65
+ method_option :all, type: :boolean, default: false,
66
+ desc: "Follow pagination until exhausted"
67
+ def list
68
+ params = build_list_params
69
+ records, total = fetch_index("/api/v1/charges", params, key: "charges")
70
+ render_index(
71
+ records, total: total,
72
+ summary: "Listed #{records.size} charges#{summary_filters_suffix}.",
73
+ breadcrumbs: [
74
+ { "cmd" => "wiq billing_profiles show <id> --profile-type ParentProfile",
75
+ "description" => "Look up the family behind a billing_profile_id" },
76
+ { "cmd" => "wiq workflows show failed-payments-recent",
77
+ "description" => "Canonical failed-payment cross-check workflow" }
78
+ ]
79
+ )
80
+ end
81
+
82
+ no_commands do
83
+ def build_list_params
84
+ params = { "per_page" => options[:per_page] || DEFAULT_PER_PAGE }
85
+ params["q[status_eq]"] = STATUS_TO_INT.fetch(options[:status]) if options[:status]
86
+ params["q[billing_profile_id_eq]"] = options[:billing_profile] if options[:billing_profile]
87
+ params["q[chargeable_type_eq]"] = options[:chargeable_type] if options[:chargeable_type]
88
+ params["q[created_at_gteq]"] = options[:since] if options[:since]
89
+ params["q[created_at_lteq]"] = options[:until] if options[:until]
90
+ params
91
+ end
92
+
93
+ def summary_filters_suffix
94
+ bits = []
95
+ bits << "status=#{options[:status]}" if options[:status]
96
+ bits << "billing_profile=#{options[:billing_profile]}" if options[:billing_profile]
97
+ bits << "since=#{options[:since]}" if options[:since]
98
+ bits << "until=#{options[:until]}" if options[:until]
99
+ bits.empty? ? "" : " (#{bits.join(", ")})"
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wiq
4
+ module Commands
5
+ class CheckIns < Base
6
+ # Match CheckIn.statuses in app/models/check_in.rb (integer-backed
7
+ # Rails enum). Ransack 4.x does not translate enum strings on integer
8
+ # columns, so the CLI translates here. Without this, q[status_eq]
9
+ # silently drops and returns all check-ins regardless of status.
10
+ STATUS_TO_INT = {
11
+ "unknown" => 0,
12
+ "present" => 1,
13
+ "absent" => 2,
14
+ "excused" => 3,
15
+ "unexcused" => 4,
16
+ "late" => 5,
17
+ "injured" => 6,
18
+ "other" => 7
19
+ }.freeze
20
+ STATUSES = STATUS_TO_INT.keys.freeze
21
+
22
+ desc "event EVENT_ID", "List check-ins for an event"
23
+ long_desc <<~DESC
24
+ Every check-in row for a single event. Each row carries the
25
+ wrestler profile, the event reference, the status (Rails enum:
26
+ unknown, present, absent, excused, unexcused, late, injured, other),
27
+ and the registration_answers captured at check-in time.
28
+
29
+ Discover event ids via `wiq events list --start ... --end ...`.
30
+ DESC
31
+ method_option :status, type: :string, enum: STATUSES,
32
+ desc: "Filter by check-in status"
33
+ method_option :all, type: :boolean, default: false, desc: "Follow pagination until exhausted"
34
+ def event(event_id)
35
+ params = { "per_page" => 50 }
36
+ params["q[status_eq]"] = STATUS_TO_INT.fetch(options[:status]) if options[:status]
37
+ records, total = fetch_index("/api/v1/events/#{event_id}/check_ins", params, key: "check_ins")
38
+ render_index(
39
+ records, total: total,
40
+ summary: "Listed #{records.size} check-ins for event #{event_id}.",
41
+ breadcrumbs: [
42
+ { "cmd" => "wiq events show #{event_id}", "description" => "See the event itself" }
43
+ ]
44
+ )
45
+ end
46
+
47
+ desc "wrestler WRESTLER_ID", "List a wrestler's check-ins"
48
+ long_desc <<~DESC
49
+ Cross-event view: every check-in this wrestler has ever had,
50
+ sorted newest-first. Use --since YYYY-MM-DD to scope.
51
+
52
+ For roster-wide attendance use `wiq check_ins summary` or
53
+ `wiq reports run LastPracticeAttendedReport --roster <id>`.
54
+ DESC
55
+ method_option :since, type: :string, desc: "Earliest created_at date (YYYY-MM-DD)"
56
+ method_option :all, type: :boolean, default: false
57
+ def wrestler(wrestler_id)
58
+ params = { "per_page" => 50 }
59
+ params["q[created_at_gteq]"] = options[:since] if options[:since]
60
+ records, total = fetch_index("/api/v1/wrestlers/#{wrestler_id}/check_ins", params, key: "check_ins")
61
+ render_index(
62
+ records, total: total,
63
+ summary: "Listed #{records.size} check-ins for wrestler #{wrestler_id}.",
64
+ breadcrumbs: [
65
+ { "cmd" => "wiq wrestlers show #{wrestler_id}",
66
+ "description" => "See the wrestler's profile" },
67
+ { "cmd" => "wiq reports run LastPracticeAttendedReport --roster <id>",
68
+ "description" => "Find ghost wrestlers across a roster" }
69
+ ]
70
+ )
71
+ end
72
+
73
+ desc "summary", "Run a CheckInSummaryReport (default) or CheckInFeedReport for a date range"
74
+ long_desc <<~DESC
75
+ Convenience wrapper around `wiq reports run`. Default report type
76
+ is CheckInSummaryReport — one row per wrestler with totals.
77
+ Pass --feed to switch to CheckInFeedReport (one row per check-in,
78
+ useful for "who came today" or capturing arrival times).
79
+
80
+ The CLI submits the report, then polls until status=ready (unless
81
+ --no-wait). Both reports are non-finance, so any CoachProfile PAT
82
+ on the team can run them.
83
+
84
+ For Q&A capture at check-in time, use
85
+ `wiq reports run CheckInReport` (the "with questions" variant).
86
+ DESC
87
+ method_option :start, type: :string, required: true, desc: "YYYY-MM-DD"
88
+ method_option :end, type: :string, required: true, desc: "YYYY-MM-DD"
89
+ method_option :roster, type: :numeric, desc: "Restrict to a single roster"
90
+ method_option :feed, type: :boolean, default: false,
91
+ desc: "Use CheckInFeedReport (raw rows) instead of the summary aggregate"
92
+ method_option :wait, type: :boolean, default: true
93
+ method_option :timeout, type: :numeric, default: 300, desc: "Max seconds to wait"
94
+ def summary
95
+ report_type = options[:feed] ? "CheckInFeedReport" : "CheckInSummaryReport"
96
+ args = {}
97
+ args["roster_id"] = options[:roster] if options[:roster]
98
+
99
+ body = {
100
+ report: {
101
+ type: report_type,
102
+ version: "v1",
103
+ name: "CLI #{report_type} #{options[:start]}..#{options[:end]}",
104
+ start_at: options[:start],
105
+ end_at: options[:end],
106
+ args: args
107
+ }
108
+ }
109
+
110
+ report = client.post("/api/v1/reports", body)
111
+ if options[:wait]
112
+ report = Reports.poll(client, report["id"], timeout: options[:timeout])
113
+ end
114
+
115
+ render(report,
116
+ summary: "#{report_type} ##{report["id"]} status=#{report["status"]}.",
117
+ breadcrumbs: [
118
+ { "cmd" => "wiq reports show #{report["id"]}", "description" => "Refetch later" },
119
+ { "cmd" => "wiq reports types", "description" => "See other recommended report types" }
120
+ ])
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wiq
4
+ module Commands
5
+ class Doctor < Base
6
+ desc "check", "Diagnose env, network, token, and config sources"
7
+ long_desc <<~DESC
8
+ Runs a sequence of diagnostic checks:
9
+
10
+ 1. Ruby version (>= 3.1 required)
11
+ 2. Credentials path (~/.config/wiq/credentials.json)
12
+ 3. Resolved host + source (--host > env > config > store > prod)
13
+ 4. Resolved alias + source
14
+ 5. Token presence + source
15
+ 6. Live reachability + auth probe via
16
+ `GET /api/v1/personal_access_tokens`
17
+ 7. Bound profile (display_name, type, team) for the calling token
18
+
19
+ Exits non-zero if any check fails. Agents should run this first
20
+ when handed an unfamiliar shell to confirm they can actually call
21
+ the API.
22
+ DESC
23
+ def check_all
24
+ checks = []
25
+
26
+ checks << check("Ruby version", RUBY_VERSION,
27
+ ok: Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.1.0"),
28
+ hint: "wiq-cli requires Ruby >= 3.1")
29
+
30
+ cfg = Wiq::Config.load(symbolized_options)
31
+ trace = cfg.trace
32
+ checks << check("Credentials path", Wiq::Credentials.path, ok: true)
33
+
34
+ checks << check("Host", trace[:host] || "(unset)",
35
+ ok: !trace[:host].nil?,
36
+ hint: trace[:host_source] || "Run `wiq auth login` to configure.")
37
+
38
+ checks << check("Alias", trace[:alias] || "(unresolved)",
39
+ ok: !trace[:alias].nil?,
40
+ hint: trace[:alias_source])
41
+
42
+ checks << check("Token", trace[:token_present] ? "present" : "missing",
43
+ ok: trace[:token_present],
44
+ hint: trace[:token_source])
45
+
46
+ if trace[:host] && trace[:token_present]
47
+ begin
48
+ client = Wiq::Client.new(cfg)
49
+ rows, = client.collect_all(
50
+ "/api/v1/personal_access_tokens",
51
+ { "per_page" => 100 },
52
+ key: "personal_access_tokens"
53
+ )
54
+ prefix = cfg.token[0, 12]
55
+ match = rows.find { |r| r["token_prefix"] == prefix }
56
+ if match
57
+ profile = match["profile"] || {}
58
+ checks << check("Reachability + auth", "200 OK", ok: true)
59
+ checks << check("Bound profile",
60
+ "#{profile["display_name"]} (#{profile["type"]}) @ #{profile["team_name"]}",
61
+ ok: !profile["display_name"].nil?)
62
+ else
63
+ checks << check("Reachability + auth", "200 OK", ok: true)
64
+ checks << check("Bound profile", "(token not in own user's PAT list?)",
65
+ ok: false,
66
+ hint: "Unexpected — the calling PAT should always appear in this list.")
67
+ end
68
+ rescue Wiq::APIError => e
69
+ checks << check("Reachability + auth", "#{e.status} #{e.code}", ok: false, hint: e.hint)
70
+ rescue Faraday::Error => e
71
+ checks << check("Reachability + auth", e.message, ok: false,
72
+ hint: "Check the host URL and your network.")
73
+ end
74
+ else
75
+ checks << check("Reachability + auth", "skipped", ok: false, hint: "Host or token missing.")
76
+ end
77
+
78
+ all_ok = checks.all? { |c| c["ok"] }
79
+ render(checks, summary: all_ok ? "All checks passed." : "Some checks failed.")
80
+ exit(1) unless all_ok
81
+ end
82
+
83
+ map "check" => :check_all
84
+ default_task :check_all
85
+
86
+ no_commands do
87
+ def check(name, value, ok:, hint: nil)
88
+ { "name" => name, "value" => value, "ok" => ok, "hint" => hint }.compact
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wiq
4
+ module Commands
5
+ class Events < Base
6
+ # Canonical event_type strings from app/models/event.rb. Surfaced via
7
+ # `wiq events types` so agents don't have to guess.
8
+ TYPES = {
9
+ "practice" => "Practice events. The default for most clubs.",
10
+ "dual_meet" => "Dual meet — match-style competition against one opposing team.",
11
+ "tournament" => "Tournament — bracket-style competition with many opponents.",
12
+ "scramble" => "Scramble — open mat / informal competition.",
13
+ "private_lesson" => "Private lesson — one-on-one or small-group paid session. " \
14
+ "Filtered out of the default `events list` unless you opt in.",
15
+ "other" => "Miscellaneous events that don't fit the other types."
16
+ }.freeze
17
+
18
+ desc "list", "List events in a date range"
19
+ long_desc <<~DESC
20
+ Fetches events between --start and --end (inclusive). Dates are parsed
21
+ in the team's default timezone server-side, so pass plain YYYY-MM-DD —
22
+ the CLI does not need timezone awareness.
23
+
24
+ Filters apply server-side:
25
+ --event-type One of `wiq events types` (practice, dual_meet, ...)
26
+ --roster One or more roster ids (the API joins through
27
+ roster_events)
28
+ --expand CSV of event_invites,event_bookings,private_lessons.
29
+ Drops nested data into each event row.
30
+
31
+ There is no server-side location/site filter today — `Event.location`
32
+ is free-text and not in `ransackable_attributes`. A location-aware
33
+ backend is on the WIQ roadmap; until then post-process client-side.
34
+
35
+ Use --all to walk every page; default is page 1, per_page=100.
36
+ DESC
37
+ method_option :start, type: :string, required: true, desc: "YYYY-MM-DD (team timezone)"
38
+ method_option :end, type: :string, required: true, desc: "YYYY-MM-DD (team timezone)"
39
+ method_option :roster, type: :array, desc: "One or more roster ids"
40
+ method_option :event_type, type: :string, enum: %w[practice dual_meet tournament scramble private_lesson other],
41
+ desc: "Filter to one event_type (see `wiq events types`)"
42
+ method_option :expand, type: :string,
43
+ desc: "CSV: event_invites, event_bookings, private_lessons"
44
+ method_option :all, type: :boolean, default: false
45
+ def list
46
+ params = {
47
+ "start" => options[:start],
48
+ "end" => options[:end],
49
+ "per_page" => 100
50
+ }
51
+ params["event_type"] = options[:event_type] if options[:event_type]
52
+ params["expand"] = options[:expand] if options[:expand]
53
+ if options[:roster]
54
+ options[:roster].each { |rid| (params["roster_ids[]"] ||= []) << rid }
55
+ end
56
+
57
+ records, total = fetch_index("/api/v1/events", params, key: "events")
58
+ render_index(
59
+ records, total: total,
60
+ summary: "Listed #{records.size} events #{options[:start]}..#{options[:end]}.",
61
+ breadcrumbs: [
62
+ { "cmd" => "wiq events show <id>", "description" => "Inspect a single event" },
63
+ { "cmd" => "wiq check_ins event <id>", "description" => "See check-ins for an event" }
64
+ ]
65
+ )
66
+ end
67
+
68
+ desc "show ID", "Fetch a single event"
69
+ long_desc <<~DESC
70
+ Single-event drill-down. The base payload covers all the obvious
71
+ fields (name, type, start/end, location, color, roster_events,
72
+ team_scores, paid_session).
73
+
74
+ Pass --expand to inline related collections:
75
+ event_invites Per-invitee RSVP status
76
+ event_bookings Per-bookee status (private lessons, paid drop-ins)
77
+ private_lessons Linked private lessons + nested bookings
78
+
79
+ Soft-deleted events are not returned.
80
+ DESC
81
+ method_option :expand, type: :string,
82
+ desc: "CSV: event_invites, event_bookings, private_lessons"
83
+ def show(id)
84
+ params = {}
85
+ params["expand"] = options[:expand] if options[:expand]
86
+ event = client.get("/api/v1/events/#{id}", params)
87
+ render(event,
88
+ summary: "Event #{event["id"]} — #{event["name"]}",
89
+ breadcrumbs: [
90
+ { "cmd" => "wiq check_ins event #{event["id"]}", "description" => "See check-ins" },
91
+ { "cmd" => "wiq reports run EventStatsReport --event #{event["id"]}",
92
+ "description" => "Run per-event stats" }
93
+ ])
94
+ end
95
+
96
+ desc "types", "Print the canonical event_type strings"
97
+ long_desc <<~DESC
98
+ Static enum sourced from app/models/event.rb. Use these values with
99
+ --event-type on `wiq events list`. Note that the API will accept
100
+ any string in the event_type column, but values outside this list
101
+ won't filter anything useful.
102
+ DESC
103
+ def types
104
+ rows = TYPES.map { |type, desc| { "event_type" => type, "description" => desc } }
105
+ render_index(rows, summary: "Canonical event_type values.")
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wiq
4
+ module Commands
5
+ class Metrics < Base
6
+ NAMES = %w[
7
+ active_subscribers
8
+ cancelled_subscriptions
9
+ category_breakdown_net
10
+ charge_avg
11
+ charge_count
12
+ gross_volume
13
+ mrr
14
+ net_volume
15
+ new_subscriptions
16
+ renewed_subscriptions
17
+ revenue_per_subscriber
18
+ ].freeze
19
+
20
+ CURRENCY_METRICS = %w[
21
+ gross_volume net_volume mrr charge_avg revenue_per_subscriber
22
+ category_breakdown_net
23
+ ].freeze
24
+
25
+ VALID_RANGES = ["today", "7d", "4w", "3m", "12m", "year to date", "custom"].freeze
26
+ VALID_INTERVALS = %w[hourly daily weekly monthly].freeze
27
+
28
+ desc "list", "Print the supported metric names"
29
+ long_desc <<~DESC
30
+ Static enumeration of the dashboard metrics WIQ exposes at
31
+ /api/v1/metrics/<name>. The `currency` flag tells you whether
32
+ values are integer cents (true) or counts (false).
33
+
34
+ Auth: every metric requires `authorize :finances, :show?`, which
35
+ in practice means admin-coach. Non-admin PATs return 403.
36
+ DESC
37
+ def list
38
+ rows = NAMES.map do |n|
39
+ { "name" => n, "currency" => CURRENCY_METRICS.include?(n) }
40
+ end
41
+ render_index(rows,
42
+ summary: "Use `wiq metrics show <name>` to fetch a specific metric. " \
43
+ "Currency values are returned in integer cents.",
44
+ breadcrumbs: [
45
+ { "cmd" => "wiq metrics show mrr", "description" => "Example: monthly recurring revenue" }
46
+ ])
47
+ end
48
+
49
+ desc "show NAME", "Fetch a single dashboard metric"
50
+ long_desc <<~DESC
51
+ Pulls /api/v1/metrics/<name> with the standard range/interval
52
+ params. The server returns both a primary_series and a
53
+ comparison_series (the previous period of the same length), plus
54
+ primary_total and comparison_total. The CLI drops the
55
+ server-rendered `charts[]` blob entirely — it's HTML for
56
+ Highcharts and not useful headless.
57
+
58
+ Ranges: today, 7d, 4w, 3m, 12m, "year to date", custom.
59
+ With --range custom you must pass --start-date and --end-date
60
+ (YYYY-MM-DD); comparison_series will be empty for custom ranges.
61
+
62
+ Intervals: hourly, daily, weekly, monthly. Invalid values are
63
+ silently coerced to `daily` server-side.
64
+
65
+ Currency metrics return integer cents (divide by 100 for dollars).
66
+ Non-currency metrics return raw counts.
67
+ DESC
68
+ method_option :range, type: :string, default: "7d", desc: VALID_RANGES.join(" | ")
69
+ method_option :interval, type: :string, default: "daily",
70
+ enum: VALID_INTERVALS, desc: "interval_group"
71
+ method_option :start_date, type: :string, desc: "Required if --range=custom"
72
+ method_option :end_date, type: :string, desc: "Required if --range=custom"
73
+ method_option :no_comparison, type: :boolean, default: false,
74
+ desc: "Drop comparison_series/comparison_total from output"
75
+ def show(name)
76
+ unless VALID_RANGES.include?(options[:range])
77
+ raise Wiq::Error.new("Invalid --range #{options[:range].inspect}",
78
+ code: "invalid_range",
79
+ hint: "Valid: #{VALID_RANGES.join(", ")}")
80
+ end
81
+
82
+ params = {
83
+ "range" => options[:range],
84
+ "interval_group" => options[:interval]
85
+ }
86
+ if options[:range] == "custom"
87
+ unless options[:start_date] && options[:end_date]
88
+ raise Wiq::Error.new("--start-date and --end-date are required when --range=custom.",
89
+ code: "missing_custom_dates")
90
+ end
91
+ params["start_date"] = options[:start_date]
92
+ params["end_date"] = options[:end_date]
93
+ end
94
+
95
+ payload = client.get("/api/v1/metrics/#{name}", params)
96
+ metrics = payload["metrics"] || {}
97
+
98
+ data = {
99
+ "name" => name,
100
+ "currency" => CURRENCY_METRICS.include?(name),
101
+ "primary_series" => metrics["primary_series"],
102
+ "primary_total" => metrics["primary_total"]
103
+ }
104
+ unless options[:no_comparison]
105
+ data["comparison_series"] = metrics["comparison_series"]
106
+ data["comparison_total"] = metrics["comparison_total"]
107
+ end
108
+
109
+ render(data,
110
+ summary: "metric=#{name} range=#{options[:range]} interval=#{options[:interval]}",
111
+ meta: { "currency_unit" => CURRENCY_METRICS.include?(name) ? "cents" : "count" },
112
+ breadcrumbs: [
113
+ { "cmd" => "wiq metrics list", "description" => "See all supported metrics" }
114
+ ])
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wiq
4
+ module Commands
5
+ class PaidSessions < Base
6
+ PRESET_TYPES = %w[
7
+ registerable guest_registerable ends_in_future recurring_registerable
8
+ recurring not_recurring not_recurring_with_archived not_archived
9
+ dropin trial trial_or_dropin
10
+ ].freeze
11
+
12
+ desc "list", "List paid sessions"
13
+ long_desc <<~DESC
14
+ Paid sessions are WIQ's registration periods — recurring (monthly
15
+ subscriptions) and one-off (clinics, camps, drop-ins). They're the
16
+ thing you point most finance reports at.
17
+
18
+ --type accepts a preset server-side scope:
19
+ registerable, guest_registerable, ends_in_future, recurring,
20
+ recurring_registerable, not_recurring, not_recurring_with_archived,
21
+ not_archived, dropin, trial, trial_or_dropin
22
+
23
+ --season filters CLI-side to sessions whose [start_at, end_at] window
24
+ overlaps the given calendar year. Pair with --all to get an exhaustive
25
+ list.
26
+
27
+ Each row embeds a `stats` block with registration counts
28
+ (good_standing_registrations_count, overdue_registrations_count,
29
+ not_canceled_registrations_count, good_standing_members_count) so
30
+ you usually don't need a follow-up call.
31
+ DESC
32
+ method_option :type, type: :string, enum: PRESET_TYPES, desc: "Preset scope filter"
33
+ method_option :season, type: :numeric, desc: "Filter to sessions overlapping calendar year"
34
+ method_option :all, type: :boolean, default: false
35
+ def list
36
+ params = { "per_page" => 50 }
37
+ params[:type] = options[:type] if options[:type]
38
+
39
+ records, total = fetch_index("/api/v1/paid_sessions", params, key: "paid_sessions")
40
+
41
+ if options[:season]
42
+ year = Integer(options[:season])
43
+ y_start = "#{year}-01-01"
44
+ y_end = "#{year}-12-31"
45
+ records = records.select do |ps|
46
+ (ps["start_at"].to_s <= y_end) && ((ps["end_at"].to_s >= y_start) || ps["end_at"].nil?)
47
+ end
48
+ end
49
+
50
+ render_index(
51
+ records, total: total,
52
+ summary: "Listed #{records.size} paid sessions#{options[:season] ? " for #{options[:season]}" : ""}.",
53
+ breadcrumbs: [
54
+ { "cmd" => "wiq paid_sessions show <id>", "description" => "Inspect one session" },
55
+ { "cmd" => "wiq reports run PaidSessionAccountingReport --paid-session <id>",
56
+ "description" => "Line-item accounting (admin only)" }
57
+ ]
58
+ )
59
+ end
60
+
61
+ desc "show ID", "Fetch a single paid session"
62
+ long_desc <<~DESC
63
+ Full payload includes name, slug, start/end dates, session_type,
64
+ registration_open flag, capacity_limit, welcome/check-in/approval/denial
65
+ text, payment_options, roster_syncers, product + upsell_product,
66
+ and the `stats` count block.
67
+ DESC
68
+ def show(id)
69
+ ps = client.get("/api/v1/paid_sessions/#{id}")
70
+ render(ps,
71
+ summary: "Paid session #{ps["id"]} — #{ps["name"]}",
72
+ breadcrumbs: [
73
+ { "cmd" => "wiq reports run PaidSessionAccountingReport --paid-session #{ps["id"]}",
74
+ "description" => "Line-item accounting (admin only)" },
75
+ { "cmd" => "wiq reports run SessionRegistrationAnswerReport --paid-session #{ps["id"]}",
76
+ "description" => "Full Q&A export" }
77
+ ])
78
+ end
79
+ end
80
+ end
81
+ end