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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 58ea5a5b9fdad1ae77611d95fab01fa1d5ed7b37b44ec6e9be9233b8b85735d5
4
+ data.tar.gz: a0e778f255afb610388a95f88cb8619fa2ca507e1e1c5fac6e6c7019edbc5e08
5
+ SHA512:
6
+ metadata.gz: 307ca5fcfa6fafec82611cb257ae300395e43ca7791c37e8eae90681c2bd31b3b3f0702d244a6ff8a6de673cc1111d5a4c82f488d149b1cceafb80f542771974
7
+ data.tar.gz: c7c540c9b1d3e14fc5b3600938ee362665bc47c1d42c4b7296b43fcc874a6db3db69b771a2062eae9d24a03002a46e5a6ab8feabadeaf0c0c93df50f9b7d08bd
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0.alpha.4
4
+
5
+ - Keep reversed payouts terminal when overlapping reads return an older completed state, for both platform and seller payouts.
6
+
7
+ ## 0.1.0.alpha.3
8
+
9
+ - Retry existing failed membership payments and expose environment-validated 3DS recovery links.
10
+ - Add permitted renewal price reductions, billing period changes and next-renewal updates.
11
+ - Add fresh merchant readiness/capability checks, ledger balances, scoped payout status and reversal handling.
12
+ - Reconcile linked customer billing and merchant transfers/payouts; report unverifiable references without importing them.
13
+ - Normalize adapter API failures under `Pay::Error`, retain provider status/type and avoid automatic write retries.
14
+ - Deliver application webhook callbacks after synchronization; add payout and trial-ending events and reusable pending-delivery recovery.
15
+ - Expand CI across Pay 12.3/12.4 and SQLite/PostgreSQL; smoke-test installation from the packaged gem in a fresh Rails app.
16
+ - Extend the sandbox demo with failed-payment retry, reconciliation, recovery links and decline/3DS test instructions.
17
+
18
+ Provider limitations remain: invoice issuance and transfers are blocked by this sandbox's verification; bank payouts are unavailable in sandbox. Plan swaps, recurring quantity and metering remain unsupported.
19
+
20
+ ## 0.1.0.alpha.2
21
+
22
+ Initial community adapter for one-time/recurring checkout, verified webhooks, saved methods, direct payments, refunds, invoices, disputes, entitlements and marketplace subaccounts. Public Rails playground with separate invoice/transfer simulations.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yaroslav Shmarov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # pay-whop
2
+
3
+ Whop payments, recurring billing and marketplace subaccounts for [Pay](https://github.com/pay-rails/pay), the Ruby on Rails payments engine.
4
+
5
+ **[Try the public sandbox demo →](https://pay-whop.onrender.com/)** — one-time payments, subscriptions, marketplace fees, saved methods and refunds with test money. Free hosting can take about a minute to wake up.
6
+
7
+ The demo also includes **[invoice and transfer simulations](https://pay-whop.onrender.com/#simulations)**. Start a browser session, then try invoice issuance, overdue/payment/void outcomes and transfer success/failure. No Whop account or KYC is needed for these controls. They update separate demo records with `sim_` references and a visible activity history; they never call Whop, create Pay charges, grant subscription access or generate provider webhooks. Reset each flow to try another outcome. These simulations do not certify real invoice issuance or transfer settlement.
8
+
9
+ **Early community adapter, not an official Whop or Pay integration.** Start in Whop's sandbox. The alpha supports direct billing and registered Whop subaccounts. Use one platform account and one environment per Rails database.
10
+
11
+ ```ruby
12
+ user.set_payment_processor(:whop)
13
+
14
+ checkout = user.payment_processor.checkout(
15
+ variant_id: "plan_...",
16
+ redirect_url: billing_url,
17
+ idempotency_key: checkout_attempt.id.to_s
18
+ )
19
+ redirect_to checkout.purchase_url, allow_other_host: true
20
+
21
+ # After Whop's verified webhook has been processed:
22
+ user.payment_processor.subscribed?
23
+ user.payment_processor.subscription.cancel
24
+ user.payment_processor.charges.last.refund!
25
+ ```
26
+
27
+ ## Status and scope
28
+
29
+ | Capability | Status |
30
+ | --- | --- |
31
+ | Hosted checkout for an existing Whop variant | Implemented |
32
+ | Link a Rails customer using signed checkout metadata | Implemented; never matched by email |
33
+ | Recurring membership and successful payment synchronization | Implemented |
34
+ | Cancel at period end, cancel immediately, reverse a pending cancellation | Implemented |
35
+ | Full and partial refunds | Implemented; exact partial refund verified in sandbox |
36
+ | Verified webhooks, duplicate record prevention, stale snapshot protection | Implemented |
37
+ | Trial and past-due state synchronization | Implemented; provider behavior still needs sandbox coverage |
38
+ | Pause/resume billing, one-time entitlements, free days and promo codes | Implemented; see provider-state caveats in the advanced guide |
39
+ | Saved methods, direct charging, authorization/capture/void | Implemented; saved-method charging verified in sandbox |
40
+ | Draft/issued invoices, invoice settlement and dispute workflows | Implemented; invoice issuance requires account verification |
41
+ | Subaccounts, onboarding/payout links, application fees, transfers | Implemented; settlement depends on provider capabilities and available balance |
42
+ | Existing-payment retry and 3DS recovery URL | Implemented; HTTP and demo error-path coverage |
43
+ | Reduce renewal price, change billing period or next renewal date | Implemented within Whop restrictions; HTTP coverage |
44
+ | Merchant capabilities, balances, payout status and reconciliation | Implemented; sandbox reads verified, bank payouts unavailable in sandbox |
45
+ | Application webhook callbacks and pending-delivery recovery job | Implemented |
46
+ | Plan swaps, proration, recurring seat changes, metered usage | Not supported |
47
+ | Seller isolation under one platform | Implemented; never mix sandbox and production in one database |
48
+ | One-time purchases | Implemented as Pay charges, not recurring subscriptions |
49
+
50
+ See the [compatibility matrix](docs/compatibility.md) and [advanced usage](docs/advanced-usage.md) for one-time purchases, saved cards, invoices, marketplace fees and payouts, and dispute workflows.
51
+
52
+ Unsupported adapter operations raise `Pay::NotSupportedError`. The SDK remains available through `Pay::Whop.client`, but SDK calls alone do not synchronize Pay records.
53
+
54
+ Requires Ruby 3.3+, Pay 12.3–12.x, and `whop_sdk` 2.7.x. The bundled demo uses Rails 8.1. API requests and webhook registration use the dated API version `2026-10-28-1`. There is no RubyGems release yet.
55
+
56
+ ## Install in a Rails application
57
+
58
+ Until published on RubyGems, install from GitHub:
59
+
60
+ ```ruby
61
+ # Gemfile
62
+ gem "pay-whop", git: "https://github.com/yshmarov/pay-whop", branch: "main"
63
+ ```
64
+
65
+ Install both engines' migrations, including Pay's STI and object-column migrations:
66
+
67
+ ```sh
68
+ bundle install
69
+ bin/rails pay:install:migrations
70
+ bin/rails pay_whop:install:migrations
71
+ bin/rails db:migrate
72
+ ```
73
+
74
+ Configure the adapter in an initializer. Keep credentials outside version control:
75
+
76
+ ```ruby
77
+ Pay::Whop.configure do |config|
78
+ config.environment = ENV.fetch("WHOP_ENVIRONMENT", "sandbox")
79
+ config.api_key = ENV.fetch("WHOP_API_KEY")
80
+ config.account_id = ENV.fetch("WHOP_ACCOUNT_ID")
81
+ config.webhook_secret = ENV.fetch("WHOP_WEBHOOK_SECRET")
82
+ end
83
+ ```
84
+
85
+ Configure your billable model as usual:
86
+
87
+ ```ruby
88
+ class User < ApplicationRecord
89
+ pay_customer
90
+ end
91
+ ```
92
+
93
+ The gem mounts `POST /pay/whop/webhooks`. For marketplaces, enable subaccount events on the platform webhook. Register a webhook on the same Whop account and API version, using the event names in `Pay::Whop::Delivery::EVENTS`. Supply its signing secret verbatim, including the `ws_` prefix.
94
+
95
+ Use an Account API key with permissions for checkout configuration creation, member reads, membership reads/updates/cancellation, and payment reads/refunds. Product/variant and webhook-management permissions are needed only if your application provisions those resources. Whop's sandbox Admin role can create checkouts; its `accounts.me` endpoint additionally requires balance-read access, so the connection checker deliberately uses the checkout API.
96
+
97
+ Run an Active Job worker. Production needs a durable queue adapter. The example uses PostgreSQL-backed GoodJob in production and the in-memory `:async` adapter for local development.
98
+
99
+ ## How synchronization works
100
+
101
+ 1. Checkout metadata contains a signed Pay customer ID, scoped to the seller account and environment. Treat the customer-specific checkout URL as private: Whop checkout configurations are reusable links, not single-use sessions.
102
+ 2. The webhook endpoint verifies Whop's signature and timestamp, checks the seller account, then persists the event ID, type and resource ID. It does not persist the raw webhook body.
103
+ 3. A job retrieves the current payment or membership from Whop. It verifies the account again and resolves the signed customer reference or an already-linked Whop member.
104
+ 4. Pay records are updated under a customer row lock. Provider `updated_at` values prevent older snapshots from overwriting newer ones. API calls run outside database transactions.
105
+ 5. Payment records become Pay charges only when their provider status is `paid`. A checkout redirect, pending payment or authorized card is not a paid charge.
106
+
107
+ A Whop member can belong to only one Pay customer in this application. Conflicting links fail explicitly. Marketplace buyers use separate seller-scoped customer records created by `merchant.customer_for(user)`; changing the configured platform or environment requires a separate database.
108
+
109
+ Duplicate webhook jobs may run, but synchronization does not create duplicate charges or subscriptions. This is not an exactly-once notification system. This version does not send Pay receipts or subscription emails; Whop's own messaging settings remain in effect.
110
+
111
+ Deliveries remain pending if processing fails. Network, rate-limit and server errors receive bounded Active Job retries; other API failures emit `delivery_failed.pay_whop` without retrying that job automatically. After fixing a persistent error, enqueue retained deliveries again:
112
+
113
+ ```ruby
114
+ Pay::Whop::RecoverDeliveriesJob.perform_later
115
+ ```
116
+
117
+ Schedule that recovery job periodically (it defaults to deliveries older than two minutes, with no one-day cutoff): a process can stop after the database write but before job enqueue. Monitor failed jobs rather than treating an HTTP 200 webhook acknowledgment as completed synchronization. Completed delivery IDs are retained for deduplication; choose a retention policy before running at scale.
118
+
119
+ ## Billing differences
120
+
121
+ - Whop variants map to Pay's `processor_plan`, memberships to subscriptions, members to customers, and payments to charges.
122
+ - A scheduled cancellation retains access through the current billing period. `resume` reverses that scheduled cancellation; it does not reactivate an ended subscription.
123
+ - `refund!` asks Whop to refund the remaining balance, or the specified integer minor-unit amount, then retrieves current payment and membership state. Refunds settle asynchronously and do not themselves cancel a subscription; cancel separately when access should end. Insufficient seller funds raise `Pay::Whop::ApiError` and leave the local refund amount unchanged.
124
+ - Pay stores integer minor units. Whop money objects are converted with `BigDecimal` and the provider's currency precision, without rounding fractions away.
125
+ - Amounts currently use Whop's account-facing `total` and `refunded_amount`, not buyer fees. Currency mismatches are rejected; adaptive-pricing/FX refunds and tax reconciliation require further validation before enabling them.
126
+ - Supply a stable `idempotency_key:` for each checkout, charge, cancellation, resumption, invoice, transfer or refund attempt. Reuse it when retrying the same operation after a timeout. A new operation needs a new key. SDK automatic retries are disabled so unexamined writes are not automatically repeated.
127
+
128
+ ## Run the example
129
+
130
+ ```sh
131
+ bundle install
132
+ cp .env.example .env
133
+ # Fill in sandbox credentials, an existing recurring WHOP_VARIANT_ID,
134
+ # never commit .env.
135
+ bundle exec bin/sandbox-check
136
+ bundle exec examples/rails/bin/setup
137
+ cd examples/rails
138
+ bundle exec bin/rails server -b 127.0.0.1 -p 3001
139
+ ```
140
+
141
+ Open `http://localhost:3001` and start a sandbox session. Each browser gets its own synthetic customer in an encrypted session cookie; all billing actions are scoped to that customer. Reuse the same browser session with your Whop sandbox account. The public playground requires PostgreSQL and a secret key; its development secret key cannot be used in production. This example is not a production SaaS starter.
142
+
143
+ Whop requires an HTTPS checkout return URL. Set `WHOP_RETURN_URL` to an HTTPS page you control; for local testing it can be a static page that links back to localhost. Expose only the webhook route through your local tunnel, register that HTTPS URL in Whop, and set `WHOP_WEBHOOK_SECRET` before restarting the server. Checkout returns to the billing page; refresh after the webhook arrives. A successful subscription, cancellation and refund should be visible in both Pay and Whop.
144
+
145
+ Recover pending local deliveries with:
146
+
147
+ ```sh
148
+ bundle exec examples/rails/bin/reconcile
149
+ ```
150
+
151
+ ## Deploy the playground
152
+
153
+ `render.yaml` defines a free Rails web service. Supply a separate persistent PostgreSQL database (for example, Neon Free); Render’s free database expires after 30 days. The app uses GoodJob with PostgreSQL-backed jobs, bounded retries and a recovery job for pending deliveries. It only boots against Whop sandbox. Free hosting has usage limits and the web service sleeps when idle.
154
+
155
+ Set the listed environment variables in the host’s secret settings. Optionally set `DEMO_MERCHANT_ID` to a registered sandbox subaccount to show marketplace checkout, and `WHOP_ONE_TIME_VARIANT_ID` to a USD 5.00 one-time variant to show draft invoices. Set `WHOP_RETURN_URL` to the public HTTPS root URL, register `/pay/whop/webhooks` with Whop using the adapter’s event list and dated API version, then set `WHOP_WEBHOOK_SECRET`. `examples/rails/bin/start` applies migrations and starts Puma. Keep a stable `SECRET_KEY_BASE`: changing it invalidates sessions and the customer references already stored in Whop.
156
+
157
+ Never expose the local developer database or reuse a production Whop API key for this demo. The public app has no admin interface and never accepts visitor API keys.
158
+
159
+ ## Develop
160
+
161
+ ```sh
162
+ bin/test
163
+ # Or:
164
+ bundle exec rake test
165
+ bundle exec standardrb
166
+ ```
167
+
168
+ The tests use a real Rails application and Pay models, WebMock at the HTTP boundary, and genuinely signed webhook requests. They require no credentials and block external network calls. CI covers Ruby 3.3/3.4/4.0 × Pay 12.3.0/12.4.0 × SQLite/PostgreSQL. `TEST_DATABASE_URL=postgresql://localhost/pay_whop_test bundle exec rake test` uses a temporary schema in a dedicated database ending in `_test`. Run `bundle exec ruby bin/install-smoke` to build the gem, extract it and install/migrate/boot a fresh Rails application. See [release instructions](docs/releasing.md). See [verification notes](docs/verification.md) for tested versions and sandbox evidence.
169
+
170
+ ## References
171
+
172
+ - [Pay processor guide](https://github.com/pay-rails/pay/blob/main/docs/contributing/adding_a_payment_processor.md)
173
+ - [Whop sandbox quickstart](https://docs.whop.com/developer/quickstart)
174
+ - [Whop Ruby SDK](https://github.com/whopio/whopsdk-ruby)
175
+ - [Whop webhook verification](https://docs.whop.com/developer/guides/webhooks)
176
+ - [Whop API stability](https://docs.whop.com/api-reference/stability)
177
+
178
+ MIT license. Contributions welcome.
@@ -0,0 +1,31 @@
1
+ module Pay
2
+ module Whop
3
+ class WebhooksController < ActionController::API
4
+ def create
5
+ raise ConfigurationError, "Set Pay::Whop.webhook_secret" if Whop.webhook_secret.blank?
6
+ event = ::WhopSDK::Helpers::VerifyWebhook.unwrap(request.raw_post,
7
+ headers: %w[webhook-id webhook-timestamp webhook-signature].to_h { |name| [name, request.headers[name]] },
8
+ key: Whop.webhook_secret).deep_stringify_keys
9
+ account = Whop.verify_account!(event)
10
+ return head :ok unless Delivery::EVENTS.include?(event["type"])
11
+ raise InvalidPayload, "Webhook ID mismatch" unless event.fetch("id") == request.headers["webhook-id"]
12
+ object = event.fetch("data")
13
+ resource_id = event["type"].start_with?("refund.") ? object.fetch("payment_id") : object.fetch("id")
14
+ raise InvalidPayload, "Missing resource ID" if resource_id.blank?
15
+ delivery = Delivery.create_or_find_by!(environment: Whop.environment, account_id: account, event_id: event.fetch("id")) do |record|
16
+ record.event_type = event.fetch("type")
17
+ record.resource_id = resource_id
18
+ end
19
+ unless delivery.event_type == event["type"] && delivery.resource_id == resource_id
20
+ raise InvalidPayload, "Webhook ID reused for a different event"
21
+ end
22
+ unless delivery.processed_at?
23
+ return head :service_unavailable unless ProcessDeliveryJob.perform_later(delivery)
24
+ end
25
+ head :ok
26
+ rescue ::StandardWebhooks::WebhookVerificationError, JSON::ParserError, KeyError, IdentityError, InvalidPayload
27
+ head :bad_request
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,22 @@
1
+ module Pay
2
+ module Whop
3
+ class ProcessDeliveryJob < ActiveJob::Base
4
+ queue_as { Whop.queue_name }
5
+ retry_on ApiError, wait: :polynomially_longer, attempts: 5 do |job, error|
6
+ job.report_failure(error)
7
+ end
8
+ retry_on ActiveRecord::Deadlocked, ActiveRecord::RecordNotUnique, wait: 2.seconds, attempts: 5
9
+
10
+ def perform(delivery)
11
+ delivery.process!
12
+ rescue ApiError => error
13
+ raise if error.retryable?
14
+ report_failure(error)
15
+ end
16
+
17
+ def report_failure(error)
18
+ ActiveSupport::Notifications.instrument("delivery_failed.pay_whop", delivery_id: arguments.first.id, status: error.status, code: error.code)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,13 @@
1
+ module Pay
2
+ module Whop
3
+ class RecoverDeliveriesJob < ActiveJob::Base
4
+ queue_as { Whop.queue_name }
5
+
6
+ def perform(older_than: 2.minutes.ago, since: nil)
7
+ scope = Delivery.pending.where(created_at: ..older_than)
8
+ scope = scope.where(created_at: since..) if since
9
+ scope.find_each { |delivery| ProcessDeliveryJob.perform_later(delivery) }
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,68 @@
1
+ module Pay
2
+ module Whop
3
+ class Charge < Pay::Charge
4
+ def self.sync(id, account_id: nil)
5
+ object = Whop.api_attributes(Whop.api.payments.retrieve(id: id, request_options: Whop.request_options))
6
+ raise InvalidPayload, "Unexpected payment ID" unless object.fetch("id") == id
7
+ Whop.verify_account!(object, expected: account_id)
8
+ subscription = Subscription.sync(object["membership_id"], account_id: object["account_id"]) if object["membership_id"].present?
9
+ return unless object.fetch("status") == "paid"
10
+ customer = Customer.resolve!(object)
11
+ if subscription && subscription.customer_id != customer.id
12
+ raise IdentityError, "Payment and membership belong to different customers"
13
+ end
14
+ customer.with_lock do
15
+ record = customer.charges.find_or_initialize_by(processor_id: id)
16
+ source_time = Whop.timestamp(object.fetch("updated_at"))
17
+ saved_time = Whop.timestamp(record.data.to_h["whop_updated_at"])
18
+ return record if saved_time && saved_time > source_time
19
+ total = object.fetch("total")
20
+ currency = total.fetch("currency").downcase
21
+ refunded = object["refunded_amount"]
22
+ record.update!(
23
+ amount: Whop.minor_units(total), currency: currency,
24
+ amount_refunded: refunded ? Whop.minor_units(refunded, currency: currency) : 0,
25
+ subscription: subscription,
26
+ created_at: Whop.timestamp(object["paid_at"] || object.fetch("created_at")),
27
+ data: record.data.to_h.merge("whop_updated_at" => source_time.iso8601(6), "payment_method_type" => object["payment_method_type"], "whop_decimals" => total.fetch("decimals"))
28
+ )
29
+ record
30
+ end
31
+ end
32
+
33
+ def sync!(**options)
34
+ customer.verify_configuration!
35
+ self.class.sync(processor_id, **options, account_id: customer.account_id)
36
+ reload
37
+ end
38
+
39
+ def api_record
40
+ customer.verify_configuration!
41
+ customer.payment(processor_id)
42
+ end
43
+
44
+ def refund!(amount_to_refund = nil, idempotency_key: SecureRandom.uuid)
45
+ customer.verify_configuration!
46
+ object = Whop.api_attributes(api_record)
47
+ customer.verify_resource!(object)
48
+ total = object.fetch("total")
49
+ remaining = Whop.minor_units(total) - Whop.minor_units(object.fetch("refunded_amount"), currency: total.fetch("currency"))
50
+ raise Error, "Payment is already fully refunded" unless remaining.positive?
51
+ body = {}
52
+ if amount_to_refund
53
+ raise ArgumentError, "Refund exceeds remaining balance" if amount_to_refund > remaining
54
+ body = {partial_amount: Whop.major_units(amount_to_refund, decimals: total.fetch("decimals"))}
55
+ end
56
+ Whop.api_request(:post, "payments/#{ERB::Util.url_encode(processor_id)}/refund", body: body, idempotency_key: idempotency_key)
57
+ sync!
58
+ end
59
+
60
+ def fees
61
+ api_record
62
+ Whop.api.payments.list_fees(id: processor_id, request_options: Whop.request_options)
63
+ end
64
+
65
+ def whop? = true
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,251 @@
1
+ module Pay
2
+ module Whop
3
+ class Customer < Pay::Customer
4
+ has_many :charges, dependent: :destroy, class_name: "Pay::Whop::Charge"
5
+ has_many :subscriptions, dependent: :destroy, class_name: "Pay::Whop::Subscription"
6
+ has_many :payment_methods, dependent: :destroy, class_name: "Pay::Whop::PaymentMethod"
7
+
8
+ has_many :entitlements, class_name: "Pay::Whop::Entitlement", dependent: :destroy
9
+ has_many :disputes, class_name: "Pay::Whop::Dispute", dependent: :destroy
10
+ has_many :invoices, class_name: "Pay::Whop::Invoice", dependent: :destroy
11
+ has_one :default_payment_method, -> { where(default: true) }, class_name: "Pay::Whop::PaymentMethod", foreign_key: :customer_id
12
+
13
+ def account_id = data.to_h["whop_account_id"] || Whop.account_id
14
+
15
+ def checkout(redirect_url:, variant_id: nil, variant: nil, name: Pay.default_product_name, metadata: {}, mode: "payment", idempotency_key: SecureRandom.uuid, **options)
16
+ bind_account!
17
+ raise ArgumentError, "Choose a variant or variant_id" if mode == "payment" && [variant_id, variant].compact.size != 1
18
+ raise ArgumentError, "Unknown checkout option" unless (options.keys - %i[affiliate_code currency payment_method_configuration three_ds_level]).empty?
19
+ Whop.api.checkout_configurations.create(**options,
20
+ account_id: account_id, variant_id: variant_id, variant: variant, mode: mode, redirect_url: redirect_url,
21
+ metadata: checkout_metadata(metadata, name: name),
22
+ request_options: Whop.request_options(idempotency_key: idempotency_key))
23
+ end
24
+
25
+ def checkout_charge(amount:, redirect_url:, currency: "usd", decimals: 2, application_fee_amount: nil, **options)
26
+ variant = {currency: currency, billing_type: "one_time", initial_price: Whop.major_units(amount, decimals: decimals)}
27
+ if application_fee_amount
28
+ raise ArgumentError, "Application fee must be below the price" unless application_fee_amount < amount
29
+ variant[:application_fee_amount] = Whop.major_units(application_fee_amount, decimals: decimals)
30
+ end
31
+ checkout(variant: variant, redirect_url: redirect_url, **options)
32
+ end
33
+
34
+ def setup_checkout(redirect_url:, **options)
35
+ checkout(redirect_url: redirect_url, mode: "setup", **options)
36
+ end
37
+
38
+ def checkout_metadata(metadata = {}, name: Pay.default_product_name)
39
+ metadata.stringify_keys.merge("pay_customer" => signed_id(purpose: Whop.identity_purpose(account_id)), "pay_subscription_name" => name)
40
+ end
41
+
42
+ def bind_account!
43
+ Whop.validate_configuration!
44
+ with_lock do
45
+ if data.to_h["whop_account_id"].present?
46
+ verify_configuration!
47
+ else
48
+ update!(data: data.to_h.merge("whop_account_id" => Whop.account_id, "whop_environment" => Whop.environment))
49
+ end
50
+ end
51
+ end
52
+
53
+ def verify_configuration!
54
+ unless data.to_h["whop_environment"] == Whop.environment && data.to_h["whop_account_id"].present?
55
+ raise IdentityError, "Pay customer belongs to another Whop environment"
56
+ end
57
+ Whop.verify_account!({"account_id" => account_id})
58
+ end
59
+
60
+ def verify_resource!(object)
61
+ verify_configuration!
62
+ Whop.verify_account!(object, expected: account_id)
63
+ unless self.class.resolve!(object).id == id
64
+ raise IdentityError, "Whop resource belongs to another customer"
65
+ end
66
+ object
67
+ end
68
+
69
+ def sync_payment_methods
70
+ verify_configuration!
71
+ raise Error, "Complete checkout or payment setup first" if processor_id.blank?
72
+ ids = []
73
+ Whop.each_page(Whop.api.payment_methods, member_id: processor_id) do |object|
74
+ ids << PaymentMethod.sync(object, customer: self).processor_id
75
+ end
76
+ payment_methods.where.not(processor_id: ids).destroy_all
77
+ payment_methods.reload
78
+ end
79
+
80
+ def add_payment_method(payment_method_id, default: false)
81
+ sync_payment_methods
82
+ method = payment_methods.find_by!(processor_id: payment_method_id)
83
+ method.make_default! if default
84
+ method
85
+ end
86
+
87
+ def create_setup_intent(confirmation_token:, return_url:, currency: "usd", metadata: {}, idempotency_key: SecureRandom.uuid)
88
+ bind_account!
89
+ Whop.api.setup_intents.create(account_id: account_id, confirmation_token: confirmation_token,
90
+ return_url: return_url, currency: currency, metadata: checkout_metadata(metadata),
91
+ request_options: Whop.request_options(idempotency_key: idempotency_key))
92
+ end
93
+
94
+ def create_payment_intent(confirmation_token:, return_url:, variant_id: nil, variant: nil, metadata: {}, capture: true, idempotency_key: SecureRandom.uuid)
95
+ bind_account!
96
+ raise ArgumentError, "Choose a variant or variant_id" unless [variant_id, variant].compact.size == 1
97
+ Whop.api.payments.create(account_id: account_id, confirmation_token: confirmation_token,
98
+ return_url: return_url, variant_id: variant_id, variant: variant, capture: capture,
99
+ metadata: checkout_metadata(metadata), request_options: Whop.request_options(idempotency_key: idempotency_key))
100
+ end
101
+
102
+ def billing_portal(name: Pay.default_product_name)
103
+ verify_configuration!
104
+ subscription(name: name)&.portal_url || raise(Error, "No Whop membership management URL is available")
105
+ end
106
+
107
+ def create_payment(variant_id: nil, variant: nil, payment_method_id: nil, off_session: true, return_url: nil, metadata: {}, name: Pay.default_product_name, capture: true, idempotency_key: SecureRandom.uuid)
108
+ verify_configuration!
109
+ raise ArgumentError, "Choose a variant or variant_id" unless [variant_id, variant].compact.size == 1
110
+ method = payment_method_id ? add_payment_method(payment_method_id) : default_payment_method
111
+ raise Error, "Save a payment method and select a default first" unless method
112
+ Whop.api.payments.create(account_id: account_id, member_id: processor_id,
113
+ payment_method_id: method.processor_id, variant_id: variant_id, variant: variant,
114
+ off_session: off_session, return_url: return_url, capture: capture,
115
+ metadata: checkout_metadata(metadata, name: name), request_options: Whop.request_options(idempotency_key: idempotency_key))
116
+ end
117
+
118
+ def charge(amount, currency: "usd", decimals: 2, **options)
119
+ result = create_payment(variant: {currency: currency, billing_type: "one_time", initial_price: Whop.major_units(amount, decimals: decimals)}, **options)
120
+ confirmed_charge(result)
121
+ end
122
+
123
+ def subscribe(plan:, name: Pay.default_product_name, **options)
124
+ result = create_payment(variant_id: plan, name: name, **options)
125
+ confirmed_charge(result).subscription || raise(Error, "Payment did not create a recurring subscription")
126
+ end
127
+
128
+ def authorize(amount, currency: "usd", decimals: 2, **options)
129
+ create_payment(variant: {currency: currency, billing_type: "one_time", initial_price: Whop.major_units(amount, decimals: decimals)}, capture: false, **options)
130
+ end
131
+
132
+ def payment(id)
133
+ result = Whop.api.payments.retrieve(id: id, request_options: Whop.request_options)
134
+ object = Whop.api_attributes(result)
135
+ raise InvalidPayload, "Unexpected payment ID" unless object.fetch("id") == id
136
+ verify_resource!(object)
137
+ result
138
+ end
139
+
140
+ def capture(payment_id, idempotency_key: SecureRandom.uuid)
141
+ payment(payment_id)
142
+ Whop.api.payments.capture(id: payment_id, request_options: Whop.request_options(idempotency_key: idempotency_key))
143
+ confirmed_charge(payment(payment_id))
144
+ end
145
+
146
+ def void(payment_id, idempotency_key: SecureRandom.uuid)
147
+ payment(payment_id)
148
+ Whop.api.payments.void(id: payment_id, request_options: Whop.request_options(idempotency_key: idempotency_key))
149
+ end
150
+
151
+ def retry_payment(payment_id, idempotency_key: SecureRandom.uuid)
152
+ payment(payment_id)
153
+ Whop.api.payments.retry_(id: payment_id, request_options: Whop.request_options(idempotency_key: idempotency_key))
154
+ confirmed_charge(payment(payment_id))
155
+ end
156
+
157
+ def confirmed_charge(result)
158
+ verify_resource!(Whop.api_attributes(result))
159
+ raise PaymentIncomplete, result unless result.status == "paid"
160
+ Charge.sync(result.id, account_id: account_id)
161
+ end
162
+
163
+ def create_invoice(items:, metadata: {}, idempotency_key: SecureRandom.uuid, **options)
164
+ verify_configuration!
165
+ raise Error, "Complete checkout or payment setup first" if processor_id.blank?
166
+ raise ArgumentError, "Unknown invoice option" unless (options.keys - %i[due_at memo payment_method_id]).empty?
167
+ add_payment_method(options[:payment_method_id]) if options[:payment_method_id]
168
+ result = Whop.api.invoices.create(**options, account_id: account_id, member_id: processor_id, items: items,
169
+ metadata: checkout_metadata(metadata), request_options: Whop.request_options(idempotency_key: idempotency_key))
170
+ Invoice.sync(result.id, account_id: account_id)
171
+ end
172
+
173
+ def invoice!(idempotency_key: SecureRandom.uuid, **options)
174
+ create_invoice(**options, idempotency_key: idempotency_key).issue(idempotency_key: "#{idempotency_key}-issue")
175
+ end
176
+
177
+ def sync_subscriptions
178
+ verify_configuration!
179
+ raise Error, "Customer has not completed checkout" if processor_id.blank?
180
+ Whop.each_page(Whop.api.memberships, account_id: account_id, member_id: processor_id) do |object|
181
+ verify_resource!(object)
182
+ Subscription.sync(object.fetch("id"), account_id: account_id)
183
+ end
184
+ subscriptions.reload
185
+ end
186
+
187
+ def reconcile!
188
+ verify_configuration!
189
+ raise Error, "Customer has not completed checkout" if processor_id.blank?
190
+ counts = {memberships: 0, payments: 0, invoices: 0, skipped: 0}
191
+ {memberships: Whop.api.memberships, payments: Whop.api.payments, invoices: Whop.api.invoices}.each do |kind, endpoint|
192
+ Whop.each_page(endpoint, account_id: account_id, member_id: processor_id) do |object|
193
+ Whop.verify_account!(object, expected: account_id)
194
+ raise IdentityError, "Reconciliation returned another member" unless object["member_id"] == processor_id
195
+ token = object.dig("metadata", "pay_customer")
196
+ if token.present? && self.class.find_signed(token, purpose: Whop.identity_purpose(account_id)).nil?
197
+ counts[:skipped] += 1
198
+ ActiveSupport::Notifications.instrument("reconciliation_skipped.pay_whop", customer_id: id, resource_id: object.fetch("id"), resource_type: kind)
199
+ next
200
+ end
201
+ verify_resource!(object)
202
+ case kind
203
+ when :memberships then Subscription.sync(object.fetch("id"), account_id: account_id)
204
+ when :payments then Charge.sync(object.fetch("id"), account_id: account_id)
205
+ when :invoices then Invoice.sync(object.fetch("id"), account_id: account_id)
206
+ end
207
+ counts[kind] += 1
208
+ end
209
+ end
210
+ counts
211
+ end
212
+
213
+ def api_record
214
+ verify_configuration!
215
+ raise Error, "Customer has not completed a Whop checkout" if processor_id.blank?
216
+ Whop.api.members.retrieve(id: processor_id, request_options: Whop.request_options)
217
+ end
218
+
219
+ def update_api_record(**attributes)
220
+ # Whop owns the buyer's email; changing it locally must not change their Whop identity.
221
+ nil
222
+ end
223
+
224
+ def whop? = true
225
+
226
+ def self.resolve!(object)
227
+ account = Whop.verify_account!(object)
228
+ member_id = object.fetch("member_id")
229
+ raise IdentityError, "Whop resource has no member" if member_id.blank?
230
+ token = object.dig("metadata", "pay_customer")
231
+ signed_customer = find_signed(token, purpose: Whop.identity_purpose(account)) if token.present?
232
+ raise IdentityError, "Invalid Pay customer reference" if token.present? && signed_customer.nil?
233
+ existing = find_by(processor_id: member_id)
234
+ if existing && signed_customer && existing.id != signed_customer.id
235
+ raise IdentityError, "Whop member is already linked to a different Pay customer"
236
+ end
237
+ customer = signed_customer || existing
238
+ raise IdentityError, "Whop resource is not linked to a Pay customer" unless customer
239
+ customer.verify_configuration!
240
+ raise IdentityError, "Customer belongs to another seller" unless customer.account_id == account
241
+ customer.with_lock do
242
+ if customer.processor_id.present? && customer.processor_id != member_id
243
+ raise IdentityError, "Pay customer is already linked to a different Whop member"
244
+ end
245
+ customer.update!(processor_id: member_id) if customer.processor_id.blank?
246
+ end
247
+ customer
248
+ end
249
+ end
250
+ end
251
+ end