pay-whop 0.1.0.alpha.4
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +178 -0
- data/app/controllers/pay/whop/webhooks_controller.rb +31 -0
- data/app/jobs/pay/whop/process_delivery_job.rb +22 -0
- data/app/jobs/pay/whop/recover_deliveries_job.rb +13 -0
- data/app/models/pay/whop/charge.rb +68 -0
- data/app/models/pay/whop/customer.rb +251 -0
- data/app/models/pay/whop/delivery.rb +39 -0
- data/app/models/pay/whop/dispute.rb +37 -0
- data/app/models/pay/whop/entitlement.rb +30 -0
- data/app/models/pay/whop/invoice.rb +61 -0
- data/app/models/pay/whop/merchant.rb +125 -0
- data/app/models/pay/whop/payment_method.rb +41 -0
- data/app/models/pay/whop/payout.rb +39 -0
- data/app/models/pay/whop/resource.rb +48 -0
- data/app/models/pay/whop/subscription.rb +180 -0
- data/app/models/pay/whop/transfer.rb +20 -0
- data/config/routes.rb +3 -0
- data/db/migrate/20261107000000_create_pay_whop_deliveries.rb +15 -0
- data/db/migrate/20261107120000_create_pay_whop_resources.rb +18 -0
- data/docs/advanced-usage.md +203 -0
- data/docs/compatibility.md +35 -0
- data/docs/releasing.md +12 -0
- data/docs/verification.md +75 -0
- data/docs/worklog/2026-11-12-demo-billing-simulations.md +9 -0
- data/docs/worklog/2026-11-12-integration-release.md +12 -0
- data/lib/pay/whop/api_proxy.rb +24 -0
- data/lib/pay/whop/engine.rb +13 -0
- data/lib/pay/whop/version.rb +5 -0
- data/lib/pay/whop.rb +186 -0
- data/lib/pay-whop.rb +1 -0
- metadata +102 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class CreatePayWhopResources < ActiveRecord::Migration[7.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :pay_whop_resources do |t|
|
|
4
|
+
t.string :type, null: false
|
|
5
|
+
t.string :processor_id, null: false
|
|
6
|
+
t.string :account_id, null: false
|
|
7
|
+
t.string :environment, null: false
|
|
8
|
+
t.references :customer, foreign_key: {to_table: :pay_customers}
|
|
9
|
+
t.references :merchant, foreign_key: {to_table: :pay_merchants}
|
|
10
|
+
t.string :status
|
|
11
|
+
t.bigint :amount
|
|
12
|
+
t.string :currency
|
|
13
|
+
t.json :data, default: {}, null: false
|
|
14
|
+
t.timestamps
|
|
15
|
+
end
|
|
16
|
+
add_index :pay_whop_resources, [:type, :environment, :account_id, :processor_id], unique: true, name: "pay_whop_resource_identity"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# Billing and marketplaces
|
|
2
|
+
|
|
3
|
+
All amounts accepted by adapter convenience methods are integer minor units: `500` means USD 5.00. Specify `decimals: 0` for JPY or `decimals: 3` for KWD when creating prices or transfers; refund precision comes from the original payment. Arbitrary inline `variant` prices follow Whop's API and use major units.
|
|
4
|
+
|
|
5
|
+
## One-time and recurring checkout
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
customer = user.set_payment_processor(:whop)
|
|
9
|
+
checkout = customer.checkout_charge(
|
|
10
|
+
amount: 500, currency: "usd", redirect_url: billing_url,
|
|
11
|
+
idempotency_key: order.id.to_s
|
|
12
|
+
)
|
|
13
|
+
redirect_to checkout.purchase_url, allow_other_host: true
|
|
14
|
+
|
|
15
|
+
# Existing recurring variant:
|
|
16
|
+
customer.checkout(variant_id: "plan_...", redirect_url: billing_url)
|
|
17
|
+
|
|
18
|
+
# Inline recurring variant, including a trial:
|
|
19
|
+
customer.checkout(
|
|
20
|
+
variant: {currency: "usd", billing_type: "renewal", renewal_price: 10,
|
|
21
|
+
billing_period: 30, trial_period_days: 7, product_id: "prod_..."},
|
|
22
|
+
redirect_url: billing_url
|
|
23
|
+
)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Successful one-time purchases become `Pay::Whop::Charge` records without a recurring subscription. They do not make `subscribed?` true. Your application fulfills the order after the verified payment is synchronized. An inline recurring variant's `initial_price` is an additional first-payment fee, not its recurring price.
|
|
27
|
+
|
|
28
|
+
## Saved methods and direct payments
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
checkout = customer.setup_checkout(redirect_url: billing_url)
|
|
32
|
+
# Redirect the buyer; setup_intent.succeeded links the saved method.
|
|
33
|
+
customer.sync_payment_methods
|
|
34
|
+
customer.add_payment_method("payt_...", default: true)
|
|
35
|
+
charge = customer.charge(500, idempotency_key: order.id.to_s)
|
|
36
|
+
subscription = customer.subscribe(plan: "plan_...", name: "default")
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The default is local to this adapter. It does not silently replace the payment method on existing Whop memberships. Use `subscription.update_payment_method("payt_...")` for that. `payment_method.detach` deletes the saved provider method after verifying it belongs to the linked member.
|
|
40
|
+
|
|
41
|
+
Direct payments can return before completion. `charge`, `subscribe`, `capture` and `retry_payment` raise `Pay::Whop::PaymentIncomplete` with an SDK `payment` object when it is not yet `paid`. Handle this as pending or buyer action required, not as a successful purchase or a reason to create another payment. Reuse the same idempotency key. Webhooks reconcile successful settlement.
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
begin
|
|
45
|
+
customer.charge(500, idempotency_key: order.id.to_s)
|
|
46
|
+
rescue Pay::Whop::PaymentIncomplete => error
|
|
47
|
+
payment_id = error.payment.id
|
|
48
|
+
if error.recovery_url
|
|
49
|
+
redirect_to error.recovery_url, allow_other_host: true
|
|
50
|
+
else
|
|
51
|
+
# Present a pending state and await the verified webhook.
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
For custom payment-element frontends, `create_payment_intent(confirmation_token:, return_url:, variant_id:)` and `create_setup_intent(confirmation_token:, return_url:)` attach the same signed identity metadata and return SDK objects. Render Whop's official elements and follow their authentication/status flow; never collect raw card numbers in Rails. These methods do not imply payment success or save a Pay charge themselves.
|
|
57
|
+
|
|
58
|
+
For authorizations, `customer.authorize(500, idempotency_key: ...)` returns the provider payment; `customer.capture(payment_id)` captures in full and reconciles it, while `customer.void(payment_id)` releases it. Whop must still accept the authorization/capture state and deadline. `customer.retry_payment(payment_id)` retries an existing payment; it does not create a new order.
|
|
59
|
+
|
|
60
|
+
## Refunds and subscriptions
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
charge.refund!(100, idempotency_key: "refund-order-42-part-1") # USD 1.00
|
|
64
|
+
charge.refund!(idempotency_key: "refund-order-42-rest") # remaining balance
|
|
65
|
+
|
|
66
|
+
subscription.pause # pause billing; retain paid-period access
|
|
67
|
+
subscription.pause(until_time: 1.week.from_now)
|
|
68
|
+
subscription.unpause # resume paused billing
|
|
69
|
+
subscription.cancel # cancel at period end
|
|
70
|
+
subscription.resume # undo scheduled cancellation or resume a pause
|
|
71
|
+
subscription.cancel_now!
|
|
72
|
+
subscription.add_free_days(7)
|
|
73
|
+
subscription.apply_promo_code(promo_code: "SAVE20")
|
|
74
|
+
subscription.update_payment_method("payt_...")
|
|
75
|
+
customer.billing_portal # existing membership's manage URL
|
|
76
|
+
customer.sync_subscriptions
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
A refund is separate from cancellation and can settle asynchronously. The adapter refreshes the provider's confirmed refunded amount, rather than setting it optimistically. Refund requests fail when funds or provider rules prevent them. SDK 2.7 types partial refunds as integers and ignores its advertised extra-body option; a small authenticated HTTP fallback sends the exact fractional amount and retains the pinned API date and idempotency key.
|
|
80
|
+
|
|
81
|
+
`paused?` describes billing collection. Access continues until the current paid period ends; both `active?` and Pay's active subscription query preserve that distinction. Whop exposes a pause through `most_recent_action == "paused_at"`, not a `paused` membership status. The adapter refreshes provider state after every mutation; it never reports a pause solely because the request returned HTTP 200.
|
|
82
|
+
|
|
83
|
+
Ended recurring subscriptions require a new checkout: Whop rejected recurring reactivation in sandbox. One-time product memberships are stored separately as `customer.entitlements`; their `active?` checks status and expiry, and `entitlement.reactivate(days: 30)` uses the one-time reactivation API.
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
subscription.retry_failed_payment(idempotency_key: "retry-payment-42")
|
|
87
|
+
subscription.retry_failed_payment(payment_id: "pay_...", idempotency_key: "retry-payment-42")
|
|
88
|
+
subscription.reduce_renewal_price(855, idempotency_key: "discount-membership-42")
|
|
89
|
+
subscription.change_billing_period(90, idempotency_key: "cadence-membership-42")
|
|
90
|
+
subscription.change_next_renewal(60.days.from_now, idempotency_key: "renew-membership-42")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Retry resolves an existing payment belonging to this membership. Already-paid payments only synchronize; no new payment is created. A still-incomplete retry raises `PaymentIncomplete`. The returned `recovery_url` is limited to HTTPS on the configured Whop environment's host; only a confirmed provider payment creates a charge.
|
|
94
|
+
|
|
95
|
+
Renewal prices can only decrease, using the existing currency precision. Whop emails the buyer about a price reduction. Billing periods accept 1–365 days; next-renewal dates must be after today in UTC. Each change sends one field and then refreshes actual membership state. Whop additionally enforces eligibility (for example, active/trialing, recurring, unpaused, auto-billed memberships for price reductions, and restrictions on invoice billing, cancellation and daily cadence). These changes are not arbitrary plan swaps or Stripe-style proration. Provider rejection leaves local state unchanged. New renewal controls have HTTP-contract tests, not completed provider billing-cycle validation.
|
|
96
|
+
|
|
97
|
+
Plan swaps, proration, recurring seat changes and usage metering are not implemented. Whop's `swaps` resource converts currencies; it is not a subscription-plan swap API.
|
|
98
|
+
|
|
99
|
+
## Invoices
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
invoice = customer.create_invoice(items: [{variant_id: "plan_...", quantity: 1}])
|
|
103
|
+
invoice.issue
|
|
104
|
+
invoice.checkout_url
|
|
105
|
+
invoice.pdf_url
|
|
106
|
+
invoice.charge(payment_method_id: "payt_...")
|
|
107
|
+
invoice.void
|
|
108
|
+
invoice.mark_uncollectible
|
|
109
|
+
invoice.unschedule
|
|
110
|
+
invoice.update_details(memo: "Order 42", due_at: 7.days.from_now)
|
|
111
|
+
invoice.deliver # Explicitly sends Whop's invoice email; only call when intended.
|
|
112
|
+
|
|
113
|
+
# Create and issue in one call, with separate stable creation/issue keys:
|
|
114
|
+
customer.invoice!(items: [{variant_id: "plan_...", quantity: 1}], idempotency_key: "invoice-42")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Invoice records store a billing summary, not the buyer's address or email. Only a paid invoice creates a Pay charge. Issuing or charging can require verified account capabilities. The demo account can create drafts but Whop currently blocks issuance until account verification is completed. No invoice emails were sent during verification.
|
|
118
|
+
|
|
119
|
+
## Marketplace / Connect-style flows
|
|
120
|
+
|
|
121
|
+
Configure the platform's Account API key and account ID normally. Add `pay_merchant` to your seller model:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
class Seller < ApplicationRecord
|
|
125
|
+
pay_merchant
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
merchant = seller.set_merchant_processor(:whop)
|
|
129
|
+
merchant.create_account(email: seller.email, title: seller.name,
|
|
130
|
+
idempotency_key: "seller-#{seller.id}")
|
|
131
|
+
link = merchant.account_link(refresh_url: onboarding_url, return_url: sellers_url)
|
|
132
|
+
# Redirect the seller to link.url for Whop's onboarding/KYC.
|
|
133
|
+
payouts = merchant.account_link(use_case: "payouts_portal",
|
|
134
|
+
refresh_url: onboarding_url, return_url: sellers_url)
|
|
135
|
+
|
|
136
|
+
buyer = merchant.customer_for(user)
|
|
137
|
+
checkout = buyer.checkout_charge(amount: 500, application_fee_amount: 100,
|
|
138
|
+
redirect_url: billing_url, idempotency_key: order.id.to_s)
|
|
139
|
+
|
|
140
|
+
# Alternatively collect on the platform, then transfer to the seller:
|
|
141
|
+
transfer = merchant.transfer(amount: 400, currency: "usd", idempotency_key: "seller-share-42")
|
|
142
|
+
transfer.status # processing, succeeded or failed; creation is not settlement
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`customer_for` creates a separate seller-scoped Pay customer without replacing the user's default payment processor. Never switch the global account ID per request. The adapter trusts only the configured platform and locally registered subaccounts whose parent was checked through Whop's API. Member references are signed per environment and seller account; webhook envelope and fetched resource accounts must agree. Reuse one environment per database.
|
|
146
|
+
|
|
147
|
+
Register the platform webhook with **subaccount events enabled** and all events in `Pay::Whop::Delivery::EVENTS`. The platform signing secret verifies those child deliveries. Successful transfers are recorded separately from Pay charges. Whop requires sufficient available funds and may require identity verification for transfers or payouts; the adapter does not bypass those requirements or mark rejected transfers successful.
|
|
148
|
+
|
|
149
|
+
`account.updated` stores the subaccount's status/capability summary. `merchant.readiness` retrieves status, capabilities and required actions. `merchant.capability_active?(:transfer)` checks one capability. `merchant.onboarding_complete?` requires an active account with card payments enabled; pass `capabilities: %w[transfer]` or another explicit list for a different workflow. This does not imply every payout method is enabled. Unknown or missing capabilities are unavailable. Account creation and redirects alone do not satisfy these checks.
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
merchant.readiness
|
|
153
|
+
merchant.balance # currency + BigDecimal balance/pending_balance/reserve_balance, in major units
|
|
154
|
+
merchant.login_link(refresh_url: onboarding_url, return_url: sellers_url) # hosted payouts portal
|
|
155
|
+
merchant.reconcile! # {transfers: ..., payouts: ...}
|
|
156
|
+
merchant.payouts.each { |payout| payout.sync! }
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Payout status is synchronized through the authenticated account-filtered list, including pagination. Whop's public payout tracking endpoint is not used to establish ownership. Platform payout events also synchronize without inventing a seller: use `Pay::Whop::Payout.where(account_id: Pay::Whop.account_id, environment: Pay::Whop.environment)` from an authorized platform-administration context. Payout records retain decimal-string amount/fee summaries in `data`, without bank details; these are major units, unlike Pay charge amounts. A completed payout may later reverse. This adapter delegates bank withdrawal initiation to Whop's hosted portal; it does not implement payout quotes or direct bank withdrawal creation. [Whop documents that payouts are unavailable in sandbox](https://docs.whop.com/developer/guides/sandbox). Sandbox tests therefore do not prove bank settlement.
|
|
160
|
+
|
|
161
|
+
## Disputes
|
|
162
|
+
|
|
163
|
+
Verified dispute webhooks create `Pay::Whop::Dispute` records tied to the original charge's customer. They retain status, reason, due date and payment ID, not the buyer or submitted evidence.
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
dispute.update_evidence(evidence: {product_description: "Digital course"})
|
|
167
|
+
dispute.upload_evidence(documents: [{document_type: "...", direct_upload_id: "..."}])
|
|
168
|
+
dispute.submit # Send the prepared evidence to Whop.
|
|
169
|
+
# Or explicitly accept the dispute:
|
|
170
|
+
dispute.accept
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Use the current SDK/OpenAPI document schema for evidence fields. Disputes were tested with synthetic HTTP fixtures, not by creating a real chargeback.
|
|
174
|
+
|
|
175
|
+
## Permissions and operations
|
|
176
|
+
|
|
177
|
+
Use custom scopes for the features you enable: checkout read/create; product/plan read/create for inline prices; member read and payment-method read/manage/charge; membership update/cancel; payment read/charge/manage; invoice read/create/update; setup-intent read/manage; dispute read/manage; payout-account read/update; and the relevant webhook receive scopes. Platform account inspection additionally requires `company:balance:read`.
|
|
178
|
+
|
|
179
|
+
Hosted onboarding/payout links delegate a bundle of financial scopes. Whop required payout destination create/read/update/delete, withdrawal read/export/withdraw, transfer read/export/create, and balance read/export in the tested sandbox. Missing scopes produce an explicit API error. Webhook-management access is needed only by provisioning tools. Use separate provisioning/runtime credentials in a production deployment if your workflow allows it.
|
|
180
|
+
|
|
181
|
+
Use a durable Active Job adapter, retain receipt IDs for replay protection, monitor pending deliveries and choose a retention policy. The demo uses PostgreSQL-backed GoodJob. Applications should scope invoice/dispute/transfer lookups through the authenticated customer or merchant; the adapter does not provide authentication for your own controllers.
|
|
182
|
+
|
|
183
|
+
## Errors, callbacks and recovery
|
|
184
|
+
|
|
185
|
+
Adapter API calls raise `Pay::Whop::ApiError < Pay::Error`, exposing `status`, provider `code`, `retryable?` and the original `provider_error`/cause. Network timeouts have no HTTP status. A timeout may follow a successful provider write: reuse the operation's idempotency key and reconcile before creating a replacement. `Pay::Whop.client` deliberately remains the raw official SDK and raises its native errors.
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
Pay::Webhooks.configure do |events|
|
|
189
|
+
events.subscribe "whop.payment.succeeded" do |event|
|
|
190
|
+
delivery = event.fetch(:delivery)
|
|
191
|
+
# Billing state is already synchronized. Fulfill idempotently using delivery.event_id.
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
Pay::Whop::RecoverDeliveriesJob.perform_later
|
|
196
|
+
customer.reconcile! # {memberships: ..., payments: ..., invoices: ..., skipped: ...}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Callbacks run after resource synchronization and before marking a delivery processed. Handler failure keeps the delivery pending; callbacks may repeat. They receive a delivery record containing IDs, not a captured provider payload. Retrieve application-owned records through the appropriate customer/merchant scope.
|
|
200
|
+
|
|
201
|
+
Customer reconciliation requires an already-linked member. It paginates that account/member's memberships, payments and invoices, and verifies resource identity before import. Unverifiable signed references (for example, another app's checkout on the same account) are skipped, counted and reported through `reconciliation_skipped.pay_whop`; they are never matched by email or silently reassigned. Preserve the Rails signing secret and investigate skips for known records after key rotation. This is not a global import of unrelated Whop customers. Reconciliation and missed-delivery recovery are separate operations; schedule both as appropriate for your application.
|
|
202
|
+
|
|
203
|
+
Monitor `delivery_failed.pay_whop` (delivery ID, HTTP status and provider code) and retained pending deliveries. The recovery job can re-enqueue failures after the underlying problem is fixed; periodic recovery will also encounter persistent errors, so alert on them rather than hiding them.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Pay compatibility
|
|
2
|
+
|
|
3
|
+
`pay-whop` is an alpha adapter for Whop's billing model. It does not claim parity with every Stripe feature. The raw SDK is available for Whop services outside payment processing.
|
|
4
|
+
|
|
5
|
+
| Pay / billing operation | Whop adapter behavior |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| `set_payment_processor`, customer association | Seller/environment-scoped Whop member identity, signed references |
|
|
8
|
+
| Hosted checkout | Existing or inline variants; one-time, recurring and setup |
|
|
9
|
+
| `charge`, `subscribe` | Saved method required; incomplete payments raise `PaymentIncomplete` |
|
|
10
|
+
| Authorization, capture, void | Full capture; fresh paid state required for Pay charge |
|
|
11
|
+
| `refund!` | Full/partial, exact currency precision, asynchronous confirmation |
|
|
12
|
+
| Cancel, cancel now, resume | Scheduled cancellation, immediate end, reversal of scheduled cancellation |
|
|
13
|
+
| Pause, unpause, free days, promo code | Dedicated Whop operations, actual provider state refreshed |
|
|
14
|
+
| Failed payment retry | Existing membership payment only; no replacement order |
|
|
15
|
+
| 3DS / action required | Provider recovery URL plus verified webhook completion |
|
|
16
|
+
| Renewal price / cadence / date | Price reductions and permitted changes; provider eligibility applies |
|
|
17
|
+
| Saved methods / default / replacement / detach | Member-scoped methods; local default; explicit subscription replacement |
|
|
18
|
+
| Customer billing portal | Existing membership's management URL |
|
|
19
|
+
| Draft/issued/paid/void/past-due/uncollectible invoices | Separate invoice summary; paid invoices create charges; issuance currently verification-blocked in this sandbox |
|
|
20
|
+
| Dispute preparation, evidence, submit, accept | HTTP-tested wrappers; no real dispute certification |
|
|
21
|
+
| One-time membership access | Separate entitlement with status/expiry, not `subscribed?` |
|
|
22
|
+
| `pay_merchant`, seller onboarding | Child account parent validation, hosted onboarding, fresh capabilities |
|
|
23
|
+
| Marketplace charges / application fees | Separate seller-scoped buyers and signed child webhooks |
|
|
24
|
+
| Internal transfers | Idempotent requests and separate settlement state; this sandbox still verification-blocked |
|
|
25
|
+
| Balances and payout status | Read/reconcile; payout initiation through hosted portal; no bank settlement proof |
|
|
26
|
+
| Webhooks / app callbacks | Signature validation, persisted IDs, account checks, fresh API reads, at-least-once callbacks |
|
|
27
|
+
| Recovery / backfill | Pending-delivery recovery job and linked-customer/merchant reconciliation |
|
|
28
|
+
| Emails / receipts | Whop messaging; Pay receipt/subscription emails are not generated by this adapter |
|
|
29
|
+
| Plan swaps / Stripe-style proration | Unsupported; price/cadence changes are narrower operations |
|
|
30
|
+
| Recurring seat quantity / metering | Unsupported; quantity remains 1 |
|
|
31
|
+
| Ended recurring reactivation | New checkout required; one-time entitlements have a separate reactivation API |
|
|
32
|
+
| Direct bank payouts / FX / tax accounting | No direct withdrawal creator or general accounting reconciliation |
|
|
33
|
+
| Whop chat, ads, cards, affiliates, trading | Outside this adapter; use the official SDK |
|
|
34
|
+
|
|
35
|
+
Implemented does not mean validated against real money or every provider state. [Verification evidence](verification.md) separates automated tests, sandbox observations and remaining limitations. [Advanced usage](advanced-usage.md) documents method signatures and provider restrictions.
|
data/docs/releasing.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
The prepared alpha is `0.1.0.alpha.4`. Installation from GitHub works without RubyGems publishing credentials.
|
|
4
|
+
|
|
5
|
+
1. Run `bin/test`, the PostgreSQL suite, and `bundle exec ruby bin/install-smoke`. Confirm the GitHub CI matrix is green.
|
|
6
|
+
2. Update the version, lockfile, changelog and verification evidence together.
|
|
7
|
+
3. Build with `gem build pay-whop.gemspec --output pkg/pay-whop-0.1.0.alpha.4.gem` (create `pkg` first).
|
|
8
|
+
4. Inspect `gem specification pkg/pay-whop-0.1.0.alpha.4.gem files` and ensure no credentials, private fixtures or developer environment files are packaged.
|
|
9
|
+
5. A gem owner publishes with `gem push pkg/pay-whop-0.1.0.alpha.4.gem`. RubyGems MFA is required by the gem metadata. Configure credentials locally; never commit or paste an API key.
|
|
10
|
+
6. Create the matching GitHub prerelease with the built artifact and release notes. Register the deployed adapter's current webhook event list only after deploying the compatible code.
|
|
11
|
+
|
|
12
|
+
Do not label this production-ready while real invoice issuance, transfers and bank payouts lack provider validation. Keep sandbox-only demo simulations visibly separate from real adapter behavior.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Verification
|
|
2
|
+
|
|
3
|
+
Provider payment-flow verification: 7 November 2026. Expanded adapter, read-only sandbox operations and demo verification: 12 November 2026. Release: `0.1.0.alpha.4`.
|
|
4
|
+
|
|
5
|
+
## Versions and automated coverage
|
|
6
|
+
|
|
7
|
+
Ruby 4.0.9, Rails 8.1.4.1, Pay 12.4.0, Whop Ruby SDK 2.7.0, and Whop API date `2026-10-28-1`. The demo runs PostgreSQL and GoodJob on Render Free / Neon Free. Unit and integration tests use SQLite and PostgreSQL, with WebMock at the SDK/HTTP boundary. No test credentials or captured customer payloads are committed.
|
|
8
|
+
|
|
9
|
+
The local suite passes **100 tests / 369 assertions** and Standard Ruby linting. The local suite also passes with Pay 12.3.0; installation from the built gem into a fresh Rails application passes. GitHub Actions runs Ruby 3.3/3.4/4.0 × Pay 12.3.0/12.4.0 × SQLite/PostgreSQL; see [CI runs](https://github.com/yshmarov/pay-whop/actions/workflows/ci.yml).
|
|
10
|
+
|
|
11
|
+
Coverage includes signed owner mapping, environment and seller isolation, conflicting identities, forged references, webhook authentication/replays/collisions, duplicate processing, pending-event recovery, enqueue refusal, stale snapshots, exact money conversion, full/partial refunds, asynchronous direct payments, saved methods, setup intents, invoice/dispute summaries, marketplace registration and transfers, terminal transfer-state protection, pause/access semantics, trial and past-due state mapping, and browser-session/CSRF/cross-customer boundaries. Expanded tests cover existing-payment retries without duplicate charges, renewal restrictions, normalized API errors including lazy pagination/timeouts, application callback failure recovery, payout ownership/pagination/reversals, merchant capabilities, decimal balances and linked-customer reconciliation.
|
|
12
|
+
|
|
13
|
+
## Actual sandbox evidence
|
|
14
|
+
|
|
15
|
+
The [hosted playground](https://pay-whop.onrender.com/) uses the adapter and receives actual signed Whop events. Its database persists across deployments and idle shutdowns. The app is sandbox-only; no live-money transactions were performed.
|
|
16
|
+
|
|
17
|
+
| Flow | Observed result |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| Hosted recurring checkout | Paid USD 24.00 including tax; one Pay charge and active subscription created by verified webhooks |
|
|
20
|
+
| Cancellation | Immediate and period-end cancellation, and reversal of scheduled cancellation, verified |
|
|
21
|
+
| Full refund | Provider reported USD 24.00 refunded; local Pay charge agreed |
|
|
22
|
+
| Saved payment methods | Retrieved a method through the linked member and selected the adapter's local default |
|
|
23
|
+
| Hosted setup checkout | Saved the test method without a charge; signed `setup_intent.succeeded` delivery processed |
|
|
24
|
+
| Hosted one-time checkout | Paid USD 6.00 including tax; signed webhook created a charge without a recurring subscription |
|
|
25
|
+
| Authorization / capture / void | Two payments became `authorized`; capture became `paid` with a Pay charge, void became `void` without a Pay charge |
|
|
26
|
+
| Direct one-time saved-method payment | Initially `open`, later `paid`; USD 6.00 including tax, with no recurring subscription |
|
|
27
|
+
| Partial refund | Requested 100 minor units; provider and Pay reported exactly USD 1.00 refunded from the USD 6.00 payment |
|
|
28
|
+
| Pause / resume billing | Provider reported `paused_at`; adapter showed billing paused while retaining paid-period access; resume cleared the pause |
|
|
29
|
+
| Free days | Added one day and observed the changed provider period end |
|
|
30
|
+
| Draft invoice | Created and persisted a USD 5.00 draft; generated an HTTPS invoice PDF URL |
|
|
31
|
+
| Invoice issue | Whop rejected issuance: account verification required. Draft remained unissued |
|
|
32
|
+
| Subaccount creation | Created a sandbox seller and verified its parent account before registering it locally |
|
|
33
|
+
| Hosted onboarding and payout links | Both generated valid sandbox Whop URLs after the required scopes were configured |
|
|
34
|
+
| Marketplace checkout | Completed a USD 5.00 subaccount checkout; provider reported USD 6.00 including tax. The fee report confirmed a collected USD 1.00 application fee; the signed child event created a charge under the seller-scoped customer |
|
|
35
|
+
| Platform transfer | Whop rejected the request because the platform business is not verified; no transfer was recorded as successful |
|
|
36
|
+
| Merchant readiness / balances (12 Nov) | Fresh account capabilities and USD ledger balances retrieved; card payments active, transfer and payout capabilities inactive |
|
|
37
|
+
| Merchant reconciliation (12 Nov) | Authenticated transfer/payout lists returned successfully with zero records; this does not prove settlement |
|
|
38
|
+
| Linked customer reconciliation (12 Nov) | One membership and one payment recovered; seven references from a different application signing context were skipped and reported |
|
|
39
|
+
| Visitor isolation | A separate hosted visitor could not see or cancel/refund another visitor's records; cross-customer actions returned 404 |
|
|
40
|
+
| Queue and deployment | PostgreSQL migrations, eager loading and persisted GoodJob execution verified; earlier deployment-time webhook failures recovered on Whop retry |
|
|
41
|
+
|
|
42
|
+
A generated checkout or payout URL is not proof of settled payment or bank withdrawal. Completed provider authentication challenges, dispute submission and evidence upload, one-time entitlement reactivation, payment-method deletion, promo-code application, and full trial/failed-renewal lifecycles have HTTP-contract coverage only where exercised by tests, not complete real-provider coverage. Do not describe them as sandbox-certified.
|
|
43
|
+
|
|
44
|
+
The adapter event list now includes 30 events (payout changes and trial-ending notifications added); deployment updates the hosted webhook to match, with subaccount events enabled. No pending deliveries remained after the checkout and setup tests. The earlier provider-flow review covered desktop layout; a full mobile/accessibility assessment remains outstanding.
|
|
45
|
+
|
|
46
|
+
## Integration findings
|
|
47
|
+
|
|
48
|
+
1. The sandbox Admin API-key role does not include `company:balance:read`, which account inspection needs. The expanded integration uses explicit custom scopes.
|
|
49
|
+
2. Hosted onboarding/payout links require additional financial scopes, including transfer/withdrawal exports and payout-destination operations. The API reports missing scopes explicitly.
|
|
50
|
+
3. Saved-payment-method listing accepts a member filter or an account filter, not both. The adapter lists through the linked member and never imports arbitrary supplied method IDs.
|
|
51
|
+
4. SDK 2.7 declares `partial_amount` as an integer even though the API accepts major-unit decimal amounts. Its JSON transport also ignores the documented `additional_body_parameters` option. Refunds therefore use a small authenticated HTTP fallback with exact decimal JSON numbers, a pinned API date, a stable idempotency key, TLS and bounded timeouts. Tests assert the outgoing fractional amount and no automatic write retry.
|
|
52
|
+
5. Payment capture returns a status object, not a complete Payment. The adapter retrieves the payment again before creating a Pay charge.
|
|
53
|
+
6. Under Rails, SDK objects must be converted via `to_h` before JSON serialization. Nullable SDK values can normalize to empty hashes; non-renewing memberships and unsettled transfer amounts are handled accordingly.
|
|
54
|
+
7. Whop's membership pause is reflected in `most_recent_action`, while the billing status can remain `active`. Pay access remains available through the current paid period.
|
|
55
|
+
8. Successful API responses may describe a pending operation. Neither a checkout return, an HTTP 200 nor a created transfer is treated as settlement.
|
|
56
|
+
9. Immediate cancellation can leave `cancel_at_period_end: true` on the provider object. Terminal status and `canceled_at` take precedence so ended subscriptions cannot retain access or appear resumable.
|
|
57
|
+
10. Refunds and transfers can fail due to available balance or account capabilities, even in the sandbox. The adapter does not bypass identity verification.
|
|
58
|
+
|
|
59
|
+
## Remaining limits
|
|
60
|
+
|
|
61
|
+
This is an alpha integration, not a declaration of production readiness or full Pay/Stripe parity. Live-money processing, high-concurrency PostgreSQL races, full accessibility assessment, completed provider SCA recovery, FX/tax reconciliation, global historical imports, compatibility beyond the tested matrix and long-running renewal/retry behavior remain unverified. Renewal price/cadence/date mutations and bank payout transitions have HTTP-contract coverage; they were not validated by running a complete real provider billing cycle. Local browser checks confirmed the recovery URL/error state and new demo controls at desktop, 320px and 390px widths; the preview used blocked-network HTTP fixtures, not a completed Whop challenge.
|
|
62
|
+
|
|
63
|
+
Plan swaps/proration, recurring seat changes and metered billing are not implemented. The Whop `swaps` API is for currencies, not subscription plans. The adapter does not implement Whop's unrelated chat, ads, trading, cards or affiliate administration APIs; callers can use the official SDK for those.
|
|
64
|
+
|
|
65
|
+
The public playground intentionally has no seller administration or money-transfer controls. Marketplace buyers use a preconfigured sandbox seller. Invoice controls create drafts until the account can issue invoices. Secrets stay in private environment settings. The app's encrypted session lasts 30 days; reusing the same Whop buyer in an unrelated new session can trigger the adapter's deliberate identity-conflict protection.
|
|
66
|
+
|
|
67
|
+
## Demo-only simulations
|
|
68
|
+
|
|
69
|
+
The public playground has a separate, amber-labelled simulation section. Invoice flows cover draft → issued → paid, past due → paid, and void outcomes. Transfer flows cover pending → succeeded/failed. Each can be reset. State and activity persist for the visitor's own demo session; another session cannot read or advance those records.
|
|
70
|
+
|
|
71
|
+
These controls only write separate demo records. They make no Whop API calls, create no provider resources, enqueue no provider webhook deliveries, create no Pay charges or subscriptions and move no funds. A simulated outcome is not proof of API compatibility, business verification, actual invoice payment or settled transfer. The gem's real invoice and transfer methods keep their provider checks and errors. The simulator is available before linking a Whop buyer, and mutation requests reject a production Whop environment.
|
|
72
|
+
|
|
73
|
+
Automated coverage checks lifecycle transitions, repeated starts, reset, terminal outcomes, session isolation, CSRF, the sandbox guard and absence of Pay billing records or external requests. Local browser checks exercised invoice issuance/overdue/payment and transfer failure before deployment. The simulation section was inspected at 320px and 390px viewport widths with no page overflow; this is not a full accessibility assessment.
|
|
74
|
+
|
|
75
|
+
The fresh-install smoke check builds and extracts the actual gem, generates a separate Rails application, installs Pay and adapter migrations in separate commands, migrates the database, eager-loads, creates a Whop customer and verifies the webhook route and recovery job. It uses no API credentials or external payment calls.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Keep the public demo usable while sandbox verification is blocked
|
|
2
|
+
|
|
3
|
+
**Outcome:** Visitors can explore labelled invoice and transfer simulations without completing KYC.
|
|
4
|
+
|
|
5
|
+
- **Keep moving:** The user chose simulations after invoice issuance and transfers remained blocked by sandbox account verification.
|
|
6
|
+
- **Honest outcomes:** A simulation demonstrates the workflow only; it is never evidence that Whop approved an account, paid an invoice or settled a transfer.
|
|
7
|
+
- **Separate visitor state:** Each browser owns its simulated scenarios so public visitors can experiment independently.
|
|
8
|
+
- **Easy retries:** Reset clears the simulated history and lets a visitor explore another outcome.
|
|
9
|
+
- **Deferred:** Real invoice issuance and transfer settlement still require validation through a provider-supported verification path. Real identity documents are not part of this demo task.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Expanded alpha ready for developers
|
|
2
|
+
|
|
3
|
+
**Outcome:** Move the integration toward a usable community release while keeping provider validation distinct from automated coverage.
|
|
4
|
+
|
|
5
|
+
- **Whop's model sets the scope.** Implement permitted renewal changes and existing-payment recovery, without promising Stripe plan swaps, proration or usage billing that the provider does not expose in the same way.
|
|
6
|
+
- **Keep moving around verification.** Preserve real invoice/transfer methods and their errors; clearly labelled demo simulations remain separate. Real-money validation is deferred until provider capability access and a specific test budget are available.
|
|
7
|
+
- **Reconcile ownership carefully.** The same sandbox account/member can contain checkouts from different application signing contexts. Report unverifiable references and skip them; never guess an owner from email.
|
|
8
|
+
- **Payout readiness is capability-specific.** Card acceptance does not prove transfers or bank payouts are available. Hosted payout initiation remains with Whop; this release adds reads and reconciliation.
|
|
9
|
+
- **Publish an alpha.** Package a reusable gem and document installation and recovery; do not claim full Stripe parity or production readiness from HTTP fixtures.
|
|
10
|
+
- **User handles new publishing credentials.** Existing RubyGems CLI credentials were subsequently found in Ruby 4's configuration location, so no additional API key was needed.
|
|
11
|
+
|
|
12
|
+
**Next step:** Complete real invoice issuance and transfer validation when Whop provides supported sandbox verification; bank settlement needs a supported environment because Whop documents payouts as unavailable in sandbox.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Pay
|
|
2
|
+
module Whop
|
|
3
|
+
class ApiProxy
|
|
4
|
+
def initialize(target)
|
|
5
|
+
@target = target
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def method_missing(name, *args, **options, &block)
|
|
9
|
+
result = Whop.with_api_errors { @target.public_send(name, *args, **options, &block) }
|
|
10
|
+
if result.class.name.start_with?("Whop_sdk::") && result.class.name.end_with?("::Client")
|
|
11
|
+
self.class.new(result)
|
|
12
|
+
elsif result.is_a?(::Whop_sdk::Internal::CursorItemIterator)
|
|
13
|
+
Enumerator.new { |output| Whop.with_api_errors { result.each { |item| output << item } } }
|
|
14
|
+
else
|
|
15
|
+
result
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def respond_to_missing?(name, include_private = false)
|
|
20
|
+
@target.respond_to?(name, include_private) || super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/pay/whop.rb
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
require "rails"
|
|
2
|
+
require "active_job/railtie"
|
|
3
|
+
require "pay"
|
|
4
|
+
require "whop_sdk"
|
|
5
|
+
require "whop_sdk/helpers/verify_webhook"
|
|
6
|
+
require "bigdecimal"
|
|
7
|
+
require "net/http"
|
|
8
|
+
require "pay/whop/version"
|
|
9
|
+
require "pay/whop/api_proxy"
|
|
10
|
+
|
|
11
|
+
module Pay
|
|
12
|
+
module Whop
|
|
13
|
+
class Error < Pay::Error; end
|
|
14
|
+
class ConfigurationError < Error; end
|
|
15
|
+
class IdentityError < Error; end
|
|
16
|
+
class InvalidPayload < Error; end
|
|
17
|
+
|
|
18
|
+
class ApiError < Error
|
|
19
|
+
attr_reader :status, :code, :provider_error
|
|
20
|
+
|
|
21
|
+
def initialize(error)
|
|
22
|
+
@provider_error = error
|
|
23
|
+
@status = error.code if error.respond_to?(:code)
|
|
24
|
+
details = begin
|
|
25
|
+
body = JSON.parse(error.message)
|
|
26
|
+
body["error"] if body.is_a?(Hash)
|
|
27
|
+
rescue JSON::ParserError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
@code = details["type"] if details.is_a?(Hash)
|
|
31
|
+
super(if details.is_a?(Hash)
|
|
32
|
+
details["message"].presence || "Whop request failed"
|
|
33
|
+
else
|
|
34
|
+
"Whop request failed#{" (HTTP #{status})" if status}"
|
|
35
|
+
end)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def retryable? = status.nil? || status == 429 || status >= 500
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class PaymentIncomplete < Error
|
|
42
|
+
attr_reader :payment
|
|
43
|
+
|
|
44
|
+
def initialize(payment)
|
|
45
|
+
@payment = payment
|
|
46
|
+
super("Whop payment #{payment.id} is #{payment.status}; payment is not confirmed")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def recovery_url
|
|
50
|
+
value = Whop.api_attributes(payment)["recovery_url"]
|
|
51
|
+
return if value.blank?
|
|
52
|
+
uri = URI.parse(value)
|
|
53
|
+
value if uri.scheme == "https" && uri.host == ((Whop.environment == "sandbox") ? "sandbox.whop.com" : "whop.com")
|
|
54
|
+
rescue URI::InvalidURIError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
mattr_accessor :api_key, :account_id, :webhook_secret
|
|
60
|
+
mattr_accessor :environment, default: "sandbox"
|
|
61
|
+
mattr_accessor :api_version, default: "2026-10-28-1"
|
|
62
|
+
mattr_accessor :queue_name, default: :default
|
|
63
|
+
|
|
64
|
+
def self.configure
|
|
65
|
+
yield self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.validate_configuration!
|
|
69
|
+
raise ConfigurationError, "Set Pay::Whop.api_key" if api_key.blank?
|
|
70
|
+
raise ConfigurationError, "Set Pay::Whop.account_id" if account_id.blank?
|
|
71
|
+
raise ConfigurationError, "Whop environment must be sandbox or production" unless %w[sandbox production].include?(environment)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.client
|
|
75
|
+
validate_configuration!
|
|
76
|
+
::Whop_sdk::Client.new(
|
|
77
|
+
token: api_key,
|
|
78
|
+
environment: (environment == "sandbox") ? ::Whop_sdk::Environment::SANDBOX : ::Whop_sdk::Environment::PRODUCTION,
|
|
79
|
+
api_version_date: api_version,
|
|
80
|
+
max_retries: 0
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.request_options(idempotency_key: nil)
|
|
85
|
+
{timeout_in_seconds: 20, additional_headers: {"Idempotency-Key" => idempotency_key}.compact}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.api = ApiProxy.new(client)
|
|
89
|
+
|
|
90
|
+
def self.with_api_errors
|
|
91
|
+
yield
|
|
92
|
+
rescue ::Whop_sdk::Errors::ApiError, Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, SocketError, EOFError, Errno::ECONNRESET, Errno::ECONNREFUSED => error
|
|
93
|
+
raise ApiError.new(error)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.each_api_page(path, **query)
|
|
97
|
+
cursor = nil
|
|
98
|
+
seen = []
|
|
99
|
+
loop do
|
|
100
|
+
page = api_request(:get, path, query: query.merge(first: 100, after: cursor).compact)
|
|
101
|
+
page.fetch("data").each { |object| yield object }
|
|
102
|
+
break unless page.dig("page_info", "has_next_page")
|
|
103
|
+
cursor = page.dig("page_info", "end_cursor")
|
|
104
|
+
raise InvalidPayload, "Invalid pagination cursor" if cursor.blank? || seen.include?(cursor)
|
|
105
|
+
seen << cursor
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def self.identity_purpose(account = account_id)
|
|
110
|
+
"pay-whop:#{environment}:#{account}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.api_attributes(object)
|
|
114
|
+
JSON.parse(object.to_h.to_json)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.verify_account!(object, expected: nil)
|
|
118
|
+
actual = object["account_id"] || object.dig("account", "id")
|
|
119
|
+
raise IdentityError, "Whop account does not match resource" if expected && actual != expected
|
|
120
|
+
registered = actual.present? && (actual == account_id || Merchant.registered?(actual))
|
|
121
|
+
unless registered
|
|
122
|
+
raise IdentityError, "Whop account is not registered with this platform"
|
|
123
|
+
end
|
|
124
|
+
actual
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.major_units(amount, decimals: 2)
|
|
128
|
+
raise ArgumentError, "Amount must be positive integer minor units" unless amount.is_a?(Integer) && amount.positive?
|
|
129
|
+
raise ArgumentError, "Invalid currency precision" unless decimals.is_a?(Integer) && (0..8).cover?(decimals)
|
|
130
|
+
value = BigDecimal(amount.to_s) / (10**decimals)
|
|
131
|
+
number = value.to_f
|
|
132
|
+
raise ArgumentError, "Amount exceeds JSON number precision" unless number.finite? && BigDecimal(number.to_s) == value
|
|
133
|
+
number
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def self.each_page(endpoint, **filters)
|
|
137
|
+
endpoint.list(**filters, first: 100, request_options: request_options).each do |object|
|
|
138
|
+
yield api_attributes(object)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# SDK 2.7 truncates refund decimals and ignores additional_body_parameters.
|
|
143
|
+
def self.api_request(method, path, body: nil, query: {}, idempotency_key: nil)
|
|
144
|
+
with_api_errors { raw_api_request(method, path, body: body, query: query, idempotency_key: idempotency_key) }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def self.raw_api_request(method, path, body:, query:, idempotency_key:)
|
|
148
|
+
validate_configuration!
|
|
149
|
+
host = (environment == "sandbox") ? "sandbox-api.whop.com" : "api.whop.com"
|
|
150
|
+
uri = URI::HTTPS.build(host: host, path: "/api/v1/#{path}")
|
|
151
|
+
uri.query = URI.encode_www_form(query) if query.any?
|
|
152
|
+
request = Net::HTTP.const_get(method.to_s.capitalize).new(uri)
|
|
153
|
+
request["Authorization"] = "Bearer #{api_key}"
|
|
154
|
+
request["Api-Version-Date"] = api_version
|
|
155
|
+
request["Content-Type"] = "application/json"
|
|
156
|
+
request["Idempotency-Key"] = idempotency_key if idempotency_key
|
|
157
|
+
request.body = JSON.generate(body) if body
|
|
158
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
159
|
+
http.use_ssl = true
|
|
160
|
+
http.open_timeout = http.read_timeout = http.write_timeout = 20
|
|
161
|
+
http.max_retries = 0
|
|
162
|
+
response = http.start { |connection| connection.request(request) }
|
|
163
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
164
|
+
error = ::Whop_sdk::Errors::ResponseError.subclass_for_code(response.code.to_i)
|
|
165
|
+
raise error.new(response.body, code: response.code.to_i)
|
|
166
|
+
end
|
|
167
|
+
response.body.to_s.empty? ? {} : JSON.parse(response.body, decimal_class: BigDecimal)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def self.minor_units(money, currency: nil)
|
|
171
|
+
raise InvalidPayload, "Missing Whop money object" unless money.is_a?(Hash)
|
|
172
|
+
raise InvalidPayload, "Currency mismatch" if currency && money.fetch("currency").downcase != currency.downcase
|
|
173
|
+
decimals = Integer(money.fetch("decimals"))
|
|
174
|
+
raise InvalidPayload, "Invalid currency precision" unless (0..8).cover?(decimals)
|
|
175
|
+
amount = BigDecimal(money.fetch("amount").to_s) * (10**decimals)
|
|
176
|
+
raise InvalidPayload, "Money is not an exact minor-unit amount" unless amount.finite? && amount.frac.zero? && amount >= 0
|
|
177
|
+
amount.to_i
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def self.timestamp(value)
|
|
181
|
+
Time.iso8601(value) if value.present?
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
require "pay/whop/engine"
|
data/lib/pay-whop.rb
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require "pay/whop"
|