waffo-pancake 0.2.1 → 0.4.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,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Waffo
6
+ module Pancake
7
+ module API
8
+ # GraphQL reads, generated from the tables below. The tables follow the schema that
9
+ # introspection reports, not the documentation pages: those show a `version` field on
10
+ # products, `prices` as a scalar and `filter` on the version queries, and none of those exist.
11
+ # test/live/queries_test.rb runs every default selection against the real schema.
12
+ #
13
+ # Filters travel as GraphQL variables, so user data is never interpolated into a query.
14
+ # `fields:` is interpolated: it must come from your code, never from a request.
15
+ module Queries
16
+ PRICES = "prices { currency priceInfo { amount taxCategory } }"
17
+ SUBSCRIPTION_PRICES = "prices { currency priceInfo { amount taxCategory trialAmount } }"
18
+ MEDIA = "media { type url alt thumbnail }"
19
+ AGGREGATE_METRICS = "amount { sum avg min max }"
20
+
21
+ STORE_FIELDS = "id name status slug logo supportEmail website prodEnabled createdAt updatedAt"
22
+ ONETIME_PRODUCT_FIELDS = "id storeId name description #{PRICES} status #{MEDIA} successUrl metadata " \
23
+ "createdAt updatedAt"
24
+ SUBSCRIPTION_PRODUCT_FIELDS = "id storeId name description billingPeriod #{SUBSCRIPTION_PRICES} status " \
25
+ "#{MEDIA} successUrl metadata createdAt updatedAt"
26
+ GROUP_FIELDS = "id storeId name description rules { sharedTrial selfServicePlanChange } productIds " \
27
+ "environment createdAt updatedAt"
28
+ ORDER_FIELDS = "id storeId status testMode buyerEmail merchantProvidedBuyerIdentity orderMerchantExternalId " \
29
+ "currency metadata createdAt updatedAt"
30
+ SUBSCRIPTION_ORDER_FIELDS = "#{ORDER_FIELDS} billingPeriod isInTrial willRenew canReactivate " \
31
+ "currentPeriodStart currentPeriodEnd canceledAt"
32
+ PAYMENT_FIELDS = "id orderId status refundStatus orderMerchantExternalId periodNumber testMode " \
33
+ "snapshotAmountDetails { subtotal listTotal discountAmount taxAmount total currency } createdAt"
34
+ REFUND_FIELDS = "id paymentId ticketId status testMode orderMerchantExternalId " \
35
+ "refundTicketMerchantExternalId pspAmountDetails { amount currency } createdAt"
36
+ REFUND_TICKET_FIELDS = "id status refundTicketMerchantExternalId " \
37
+ "versionData { reason requestedAmount { amount currency } } reviewNote rejectReason " \
38
+ "executedAt createdAt updatedAt"
39
+ CHARGEBACK_FIELDS = "id paymentId orderId storeId status reason reasonCode amount { amount currency } " \
40
+ "testMode createdAt updatedAt"
41
+ CUSTOMER_FIELDS = "id email name country status testMode orderCount subscriptionCount " \
42
+ "activeSubscriptionCount createdAt updatedAt"
43
+ COUPON_FIELDS = "id storeId name code status redeemable discountType percentOff amountOff { amount currency } " \
44
+ "duration durationInPeriods productIds maxRedemptions redeemBy timesRedeemed " \
45
+ "promotionCodesCount createdAt updatedAt"
46
+ PROMOTION_CODE_FIELDS = "id couponId code status batchId timesRedeemed createdAt"
47
+ DISCOUNT_APPLICATION_FIELDS = "id orderId couponId promotionCodeId code sourceType entryChannel appliedVia " \
48
+ "status listAmount { amount currency } discountAmount { amount currency } createdAt"
49
+ EMAIL_DELIVERY_FIELDS = "id storeId eventType eventId recipientType toAddress subject testMode status " \
50
+ "sentAt deliveredAt bounceType errorMessage createdAt"
51
+ CANCEL_REASON_FIELDS = "id orderId storeId reasonCode reasonDetail createdAt"
52
+ MERCHANT_FIELDS = "id email name status createdAt updatedAt"
53
+ STORE_MERCHANT_FIELDS = "id storeId merchantId role memberEmail memberName createdAt updatedAt"
54
+ WEBHOOK_FIELDS = "id storeId channel url events testMode secret createdAt updatedAt"
55
+ RECIPIENT_FIELDS = "id storeId email isPrimary createdAt preferences { notifyNewOrders notifyNewSubscriptions " \
56
+ "notifySubscriptionCanceled notifySubscriptionRenewed notifySubscriptionEnded " \
57
+ "notifySubscriptionPastDue notifySubscriptionUncanceled notifySubscriptionPlanChanged " \
58
+ "notifyChargeback notifyRefundSucceeded }"
59
+ ONETIME_VERSION_FIELDS = "id productId versionNumber name description #{PRICES} #{MEDIA} successUrl metadata " \
60
+ "createdAt isTestVersion isProdVersion"
61
+ SUBSCRIPTION_VERSION_FIELDS = "id productId versionNumber name description billingPeriod " \
62
+ "#{SUBSCRIPTION_PRICES} #{MEDIA} successUrl metadata createdAt isTestVersion " \
63
+ "isProdVersion"
64
+
65
+ # scope: how the list is tied to its owner.
66
+ # :store storeId: String! (defaults to the configured store)
67
+ # :store_slug storeId or storeSlug, both optional
68
+ # :store_opt storeId: String, optional
69
+ # :coupon couponId: String!
70
+ # nil no owner argument
71
+ LISTS = {
72
+ stores: { field: "stores", type: "Store", order: true, fields: STORE_FIELDS },
73
+ merchants: { field: "merchants", type: "Merchant", order: true, single: :merchant, fields: MERCHANT_FIELDS },
74
+ store_merchants: { field: "storeMerchants", type: "StoreMerchant", single: :store_merchant,
75
+ fields: STORE_MERCHANT_FIELDS },
76
+ onetime_products: { field: "onetimeProducts", type: "OnetimeProduct", scope: :store_slug,
77
+ single: :onetime_product, fields: ONETIME_PRODUCT_FIELDS },
78
+ subscription_products: { field: "subscriptionProducts", type: "SubscriptionProduct", scope: :store_slug,
79
+ single: :subscription_product, fields: SUBSCRIPTION_PRODUCT_FIELDS },
80
+ subscription_product_groups: { field: "subscriptionProductGroups", type: "SubscriptionProductGroup",
81
+ scope: :store_slug, single: :subscription_product_group, fields: GROUP_FIELDS },
82
+ onetime_orders: { field: "onetimeOrders", type: "OnetimeOrder", scope: :store_slug, order: true,
83
+ single: :onetime_order, fields: ORDER_FIELDS },
84
+ subscription_orders: { field: "subscriptionOrders", type: "SubscriptionOrder", scope: :store_slug, order: true,
85
+ fields: SUBSCRIPTION_ORDER_FIELDS },
86
+ payments: { field: "payments", type: "Payment", scope: :store_opt, order: true, single: :payment,
87
+ fields: PAYMENT_FIELDS },
88
+ refunds: { field: "refunds", type: "Refund", order: true, single: :refund, fields: REFUND_FIELDS },
89
+ refund_tickets: { field: "refundTickets", type: "RefundTicket", order: true, single: :refund_ticket,
90
+ fields: REFUND_TICKET_FIELDS },
91
+ chargebacks: { field: "chargebacks", type: "Chargeback", order: true, single: :chargeback,
92
+ fields: CHARGEBACK_FIELDS },
93
+ customers: { field: "customers", type: "Customer", scope: :store, order: true, fields: CUSTOMER_FIELDS },
94
+ coupons: { field: "coupons", type: "Coupon", scope: :store, single: :coupon, fields: COUPON_FIELDS },
95
+ promotion_codes: { field: "promotionCodes", type: "PromotionCode", scope: :coupon,
96
+ fields: PROMOTION_CODE_FIELDS },
97
+ discount_applications: { field: "discountApplications", type: "DiscountApplication", scope: :store,
98
+ fields: DISCOUNT_APPLICATION_FIELDS },
99
+ email_deliveries: { field: "emailDeliveries", type: "EmailDelivery", scope: :store, order: true,
100
+ fields: EMAIL_DELIVERY_FIELDS },
101
+ subscription_cancel_reasons: { field: "subscriptionCancelReasons", type: "SubscriptionCancelReason",
102
+ scope: :store, order: true, fields: CANCEL_REASON_FIELDS },
103
+ store_webhooks: { field: "storeWebhooks", type: "StoreWebhook", scope: :store, fields: WEBHOOK_FIELDS }
104
+ }.freeze
105
+
106
+ # method => [GraphQL field, type prefix, reports an amount]. Delivery and reason aggregates only count.
107
+ AGGREGATES = {
108
+ payments_aggregate: ["paymentsAggregate", "Payment", true],
109
+ refunds_aggregate: ["refundsAggregate", "Refund", true],
110
+ onetime_orders_aggregate: ["onetimeOrdersAggregate", "OnetimeOrder", true],
111
+ subscription_orders_aggregate: ["subscriptionOrdersAggregate", "SubscriptionOrder", true],
112
+ payout_tickets_aggregate: ["payoutTicketsAggregate", "PayoutTicket", true],
113
+ settlement_batches_aggregate: ["settlementBatchesAggregate", "SettlementBatch", true],
114
+ webhook_deliveries_aggregate: ["webhookDeliveriesAggregate", "WebhookDelivery", false],
115
+ email_deliveries_aggregate: ["emailDeliveriesAggregate", "EmailDelivery", false],
116
+ subscription_cancel_reasons_aggregate: ["subscriptionCancelReasonsAggregate", "SubscriptionCancelReason", false]
117
+ }.freeze
118
+
119
+ ANALYTICS = {
120
+ order_statistics: "orderStatistics",
121
+ payment_statistics: "paymentStatistics",
122
+ product_statistics: "productStatistics",
123
+ trend_analysis: "trendAnalysis",
124
+ distribution_analysis: "distributionAnalysis",
125
+ customer_analysis: "customerAnalysis",
126
+ tax_analysis: "taxAnalysis",
127
+ subscription_analysis: "subscriptionAnalysis",
128
+ refund_ticket_analysis: "refundTicketAnalysis",
129
+ settlement_analysis: "settlementAnalysis",
130
+ delivery_analysis: "deliveryAnalysis"
131
+ }.freeze
132
+
133
+ OWNER_KEYS = { store: [:store_id], store_slug: %i[store_id store_slug], store_opt: [:store_id],
134
+ coupon: [:coupon_id], nil => [] }.freeze
135
+
136
+ # What a stand-in such as TestHelpers::FakeClient should answer, by method name.
137
+ def self.empty_answers
138
+ @empty_answers ||= begin
139
+ answers = {}
140
+ LISTS.each do |name, spec|
141
+ answers[name] = []
142
+ answers[:"#{name}_count"] = 0
143
+ answers[spec[:single]] = nil if spec[:single]
144
+ end
145
+ answers[:onetime_product_versions] = []
146
+ answers[:subscription_product_versions] = []
147
+ answers[:notification_recipients] = []
148
+ answers[:notification_recipients_count] = 0
149
+ answers[:store] = answers[:me] = answers[:payout_analysis] = nil
150
+ AGGREGATES.each_key { |name| answers[name] = nil }
151
+ ANALYTICS.each_key { |name| answers[name] = nil }
152
+ answers.freeze
153
+ end
154
+ end
155
+
156
+ LISTS.each do |name, spec|
157
+ define_method(name) do |filter: nil, order_by: nil, limit: nil, offset: nil, fields: spec[:fields], **owner|
158
+ arguments = owner_arguments(spec[:scope], owner, name)
159
+ arguments[:limit] = ["Int", limit && Integer(limit)]
160
+ arguments[:offset] = ["Int", offset && Integer(offset)]
161
+ arguments[:filter] = ["#{spec[:type]}Filter", filter]
162
+ raise ArgumentError, "#{name} takes no order_by" if order_by && !spec[:order]
163
+
164
+ arguments[:orderBy] = ["[#{spec[:type]}OrderBy!]", order_by && Array(order_by).map(&:to_s)]
165
+ run_query(spec[:field], arguments, fields) || []
166
+ end
167
+
168
+ define_method(:"#{name}_count") do |filter: nil, **owner|
169
+ arguments = owner_arguments(spec[:scope], owner, name)
170
+ arguments[:filter] = ["#{spec[:type]}Filter", filter]
171
+ run_query("#{spec[:field]}Count", arguments, nil)
172
+ end
173
+
174
+ next unless spec[:single]
175
+
176
+ field = spec[:single].to_s.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }
177
+ define_method(spec[:single]) do |id, fields: spec[:fields]|
178
+ run_query(field, { id: ["String!", id] }, fields)
179
+ end
180
+ end
181
+
182
+ AGGREGATES.each do |name, (field, type, has_amount)|
183
+ default_fields = "count #{AGGREGATE_METRICS if has_amount} " \
184
+ "groups { keys { dimension value } count #{AGGREGATE_METRICS if has_amount} } isTruncated"
185
+ define_method(name) do |filter: nil, group_by: nil, granularity: nil, fields: default_fields|
186
+ run_query(field, {
187
+ filter: ["#{type}Filter", filter],
188
+ groupBy: ["[#{type}Dimension!]", group_by && Array(group_by).map(&:to_s)],
189
+ granularity: ["TimeGranularity", granularity&.to_s]
190
+ }, fields)
191
+ end
192
+ end
193
+
194
+ ANALYTICS.each do |name, field|
195
+ define_method(name) do |fields:, start_date:, end_date:, currency: nil, status: nil, **owner|
196
+ arguments = owner_arguments(:store_slug, owner, name)
197
+ if arguments.values.all? { |_, value| value.nil? }
198
+ raise ArgumentError, "#{name} needs store_id or store_slug"
199
+ end
200
+
201
+ arguments[:filter] = ["AnalyticsFilterInput!", analytics_filter(start_date, end_date, currency, status)]
202
+ run_query(field, arguments, fields)
203
+ end
204
+ end
205
+
206
+ def payout_analysis(fields:, start_date:, end_date:, merchant_id: nil, currency: nil, status: nil)
207
+ run_query("payoutAnalysis", {
208
+ merchantId: ["String", merchant_id],
209
+ filter: ["AnalyticsFilterInput!", analytics_filter(start_date, end_date, currency, status)]
210
+ }, fields)
211
+ end
212
+
213
+ # Defaults to the configured store; pass `slug:` to find a store by slug instead.
214
+ def store(id = nil, slug: nil, fields: STORE_FIELDS)
215
+ id ||= @store_id unless slug
216
+ raise ArgumentError, "id or slug is required" if blank?(id) && blank?(slug)
217
+
218
+ run_query("store", { id: ["String", id], slug: ["String", slug] }, fields)
219
+ end
220
+
221
+ # The merchant the API key belongs to.
222
+ def me(fields: MERCHANT_FIELDS)
223
+ run_query("me", {}, fields)
224
+ end
225
+
226
+ def onetime_product_versions(product_id, fields: ONETIME_VERSION_FIELDS)
227
+ run_query("onetimeProductVersions", { productId: ["String!", product_id] }, fields) || []
228
+ end
229
+
230
+ def subscription_product_versions(product_id, fields: SUBSCRIPTION_VERSION_FIELDS)
231
+ run_query("subscriptionProductVersions", { productId: ["String!", product_id] }, fields) || []
232
+ end
233
+
234
+ def notification_recipients(store_id: @store_id, limit: nil, offset: nil, fields: RECIPIENT_FIELDS)
235
+ run_query("storeNotificationRecipients", {
236
+ storeId: ["String!", require_store_id(store_id)],
237
+ limit: ["Int", limit && Integer(limit)],
238
+ offset: ["Int", offset && Integer(offset)]
239
+ }, fields) || []
240
+ end
241
+
242
+ def notification_recipients_count(store_id: @store_id)
243
+ run_query("storeNotificationRecipientsCount", { storeId: ["String!", require_store_id(store_id)] }, nil)
244
+ end
245
+
246
+ private
247
+ def owner_arguments(scope, owner, name)
248
+ unknown = owner.keys - OWNER_KEYS.fetch(scope)
249
+ # An unknown owner would be dropped, and the query would then read every store.
250
+ raise ArgumentError, "#{name} does not take #{unknown.join(", ")}" unless unknown.empty?
251
+
252
+ store_id = owner.fetch(:store_id, @store_id)
253
+ case scope
254
+ when :store then { storeId: ["String!", require_store_id(store_id)] }
255
+ when :coupon
256
+ raise ArgumentError, "#{name} needs coupon_id" if blank?(owner[:coupon_id])
257
+
258
+ { couponId: ["String!", owner[:coupon_id]] }
259
+ when :store_opt then { storeId: ["String", store_id] }
260
+ when :store_slug
261
+ # A slug names the store on its own, so the configured store must not join it.
262
+ slug = owner[:store_slug]
263
+ { storeId: ["String", slug ? nil : store_id], storeSlug: ["String", slug] }
264
+ else {}
265
+ end
266
+ end
267
+
268
+ # `arguments` is { name: [GraphQL type, value] }; a nil value leaves the argument out.
269
+ def run_query(field, arguments, fields)
270
+ given = arguments.reject { |_, (_, value)| value.nil? }
271
+ declared = given.map { |name, (type, _)| "$#{name}: #{type}" }.join(", ")
272
+ passed = given.keys.map { |name| "#{name}: $#{name}" }.join(", ")
273
+ query = "query#{" (#{declared})" unless given.empty?} { #{field}#{"(#{passed})" unless given.empty?}" \
274
+ "#{" { #{fields} }" if fields} }"
275
+ data = graphql(query, given.transform_values(&:last))
276
+ data && data[field]
277
+ end
278
+
279
+ def analytics_filter(start_date, end_date, currency, status)
280
+ filter = { timeRange: { startDate: time_string(start_date), endDate: time_string(end_date) } }
281
+ filter[:currency] = currency if currency
282
+ filter[:status] = status if status
283
+ filter
284
+ end
285
+
286
+ def time_string(value)
287
+ value.respond_to?(:utc) ? value.dup.utc.iso8601 : value.to_s
288
+ end
289
+ end
290
+ end
291
+ end
292
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waffo
4
+ module Pancake
5
+ module API
6
+ # Stores, their members, webhook endpoints and notification recipients. Merchant API key.
7
+ module StoreActions
8
+ def create_store(name:, **params)
9
+ action("/v1/actions/store/create-store", name: name, **params)
10
+ end
11
+
12
+ # Omitted keeps a field, Waffo::Pancake::NULL clears it, a value sets it.
13
+ def update_store(id:, **params)
14
+ action("/v1/actions/store/update-store", id: id, **params)
15
+ end
16
+
17
+ def delete_store(id:, **params)
18
+ action("/v1/actions/store/delete-store", id: id, **params)
19
+ end
20
+
21
+ def add_store_merchant(store_id:, email:, role:, **params)
22
+ action("/v1/actions/store-merchant/add-merchant", store_id: store_id, email: email, role: role, **params)
23
+ end
24
+
25
+ def remove_store_merchant(store_id:, merchant_id:, **params)
26
+ action("/v1/actions/store-merchant/remove-merchant", store_id: store_id, merchant_id: merchant_id, **params)
27
+ end
28
+
29
+ def update_store_merchant_role(store_id:, merchant_id:, role:, **params)
30
+ action("/v1/actions/store-merchant/update-role", store_id: store_id, merchant_id: merchant_id, role: role,
31
+ **params)
32
+ end
33
+
34
+ def add_webhook(store_id:, url:, events:, test_mode:, channel: "http", **params)
35
+ action("/v1/actions/store/add-webhook", store_id: store_id, channel: channel, url: url, events: events,
36
+ test_mode: test_mode, **params)
37
+ end
38
+
39
+ # `secret: Waffo::Pancake::NULL` clears the secret.
40
+ def update_webhook(id:, **params)
41
+ action("/v1/actions/store/update-webhook", id: id, **params)
42
+ end
43
+
44
+ def remove_webhook(id:, **params)
45
+ action("/v1/actions/store/remove-webhook", id: id, **params)
46
+ end
47
+
48
+ def add_notification_recipient(store_id:, email:, **params)
49
+ action("/v1/actions/store-notification-recipient/add-recipient", store_id: store_id, email: email, **params)
50
+ end
51
+
52
+ def update_notification_recipient(store_id:, recipient_id:, preferences:, **params)
53
+ action("/v1/actions/store-notification-recipient/update-recipient",
54
+ store_id: store_id, recipient_id: recipient_id, preferences: preferences, **params)
55
+ end
56
+
57
+ def remove_notification_recipient(store_id:, recipient_id:, **params)
58
+ action("/v1/actions/store-notification-recipient/remove-recipient",
59
+ store_id: store_id, recipient_id: recipient_id, **params)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end