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,39 @@
|
|
|
1
|
+
module Pay
|
|
2
|
+
module Whop
|
|
3
|
+
class Delivery < Pay::ApplicationRecord
|
|
4
|
+
self.table_name = "pay_whop_deliveries"
|
|
5
|
+
|
|
6
|
+
EVENTS = %w[payment.succeeded payment.failed payment.pending payment.canceled membership.activated membership.deactivated membership.updated refund.created refund.updated payment.authorized payment.requires_action setup_intent.succeeded setup_intent.canceled setup_intent.requires_action invoice.created invoice.paid invoice.past_due invoice.voided invoice.marked_uncollectible dispute.created dispute.updated transfer.created transfer.completed transfer.failed membership.cancel_at_period_end_changed account.updated payout.created payout.updated payout.reversed membership.trial_ending_soon].freeze
|
|
7
|
+
|
|
8
|
+
scope :pending, -> { where(processed_at: nil) }
|
|
9
|
+
|
|
10
|
+
def process!
|
|
11
|
+
return if processed_at?
|
|
12
|
+
unless environment == Whop.environment
|
|
13
|
+
raise IdentityError, "Webhook belongs to another Whop account or environment"
|
|
14
|
+
end
|
|
15
|
+
Whop.verify_account!({"account_id" => account_id})
|
|
16
|
+
case event_type
|
|
17
|
+
when /\Amembership\./
|
|
18
|
+
Subscription.sync(resource_id, account_id: account_id)
|
|
19
|
+
when "account.updated"
|
|
20
|
+
Merchant.sync(resource_id, account_id: account_id) unless resource_id == Whop.account_id
|
|
21
|
+
when /\Asetup_intent\./
|
|
22
|
+
PaymentMethod.sync_setup(resource_id, account_id: account_id)
|
|
23
|
+
when /\Ainvoice\./
|
|
24
|
+
Invoice.sync(resource_id, account_id: account_id)
|
|
25
|
+
when /\Adispute\./
|
|
26
|
+
Dispute.sync(resource_id, account_id: account_id)
|
|
27
|
+
when /\Apayout\./
|
|
28
|
+
Payout.sync(resource_id, account_id: account_id)
|
|
29
|
+
when /\Atransfer\./
|
|
30
|
+
Transfer.sync(resource_id, account_id: account_id)
|
|
31
|
+
else
|
|
32
|
+
Charge.sync(resource_id, account_id: account_id)
|
|
33
|
+
end
|
|
34
|
+
Pay::Webhooks.instrument(type: "whop.#{event_type}", event: {delivery: self})
|
|
35
|
+
update!(processed_at: Time.current)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Pay
|
|
2
|
+
module Whop
|
|
3
|
+
class Dispute < Resource
|
|
4
|
+
def self.sync(id, account_id:)
|
|
5
|
+
object = Whop.api_attributes(Whop.api.disputes.retrieve(id: id, request_options: Whop.request_options))
|
|
6
|
+
raise InvalidPayload, "Unexpected dispute ID" unless object.fetch("id") == id
|
|
7
|
+
Whop.verify_account!(object, expected: account_id)
|
|
8
|
+
payment_id = object.fetch("payment").fetch("id")
|
|
9
|
+
charge = Charge.sync(payment_id, account_id: account_id)
|
|
10
|
+
raise InvalidPayload, "Dispute payment is not a confirmed charge" unless charge
|
|
11
|
+
persist_snapshot(object, account_id: account_id, owner: charge.customer,
|
|
12
|
+
attributes: {currency: object["currency"], data: object.slice("reason", "evidence_due_at", "evidence_editable", "acceptable", "submission_failure_reason").merge("payment_id" => payment_id)})
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def update_evidence(evidence:, idempotency_key: SecureRandom.uuid)
|
|
16
|
+
verify_configuration!
|
|
17
|
+
Whop.api.disputes.update(id: processor_id, evidence: evidence, request_options: Whop.request_options(idempotency_key: idempotency_key))
|
|
18
|
+
sync!
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def upload_evidence(documents:, idempotency_key: SecureRandom.uuid)
|
|
22
|
+
verify_configuration!
|
|
23
|
+
Whop.api.disputes.upload_evidence(id: processor_id, documents: documents,
|
|
24
|
+
request_options: Whop.request_options(idempotency_key: idempotency_key))
|
|
25
|
+
sync!
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
%i[submit accept].each do |operation|
|
|
29
|
+
define_method(operation) do |idempotency_key: SecureRandom.uuid|
|
|
30
|
+
verify_configuration!
|
|
31
|
+
Whop.api.disputes.public_send(operation, id: processor_id, request_options: Whop.request_options(idempotency_key: idempotency_key))
|
|
32
|
+
sync!
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Pay
|
|
2
|
+
module Whop
|
|
3
|
+
class Entitlement < Resource
|
|
4
|
+
def self.sync(id, account_id:)
|
|
5
|
+
object = Whop.api_attributes(Whop.api.memberships.retrieve(id: id, request_options: Whop.request_options))
|
|
6
|
+
raise InvalidPayload, "Unexpected membership ID" unless object.fetch("id") == id
|
|
7
|
+
sync_object(object, account_id: account_id)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.sync_object(object, account_id:)
|
|
11
|
+
Whop.verify_account!(object, expected: account_id)
|
|
12
|
+
raise InvalidPayload, "Recurring memberships are subscriptions" if object["renewal_price"].present?
|
|
13
|
+
customer = Customer.resolve!(object)
|
|
14
|
+
persist_snapshot(object, account_id: account_id, owner: customer,
|
|
15
|
+
attributes: {data: object.slice("product_id", "variant_id", "expires_at", "current_period_end", "manage_url")})
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def active?
|
|
19
|
+
expiry = Whop.timestamp(data.to_h["expires_at"])
|
|
20
|
+
%w[completed active trialing].include?(status) && (expiry.nil? || expiry.future?)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reactivate(days: nil, idempotency_key: SecureRandom.uuid)
|
|
24
|
+
verify_configuration!
|
|
25
|
+
Whop.api.memberships.reactivate(id: processor_id, days: days, request_options: Whop.request_options(idempotency_key: idempotency_key))
|
|
26
|
+
sync!
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module Pay
|
|
2
|
+
module Whop
|
|
3
|
+
class Invoice < Resource
|
|
4
|
+
def self.sync(id, account_id:)
|
|
5
|
+
object = Whop.api_attributes(Whop.api.invoices.retrieve(id: id, request_options: Whop.request_options))
|
|
6
|
+
raise InvalidPayload, "Unexpected invoice ID" unless object.fetch("id") == id
|
|
7
|
+
Whop.verify_account!(object, expected: account_id)
|
|
8
|
+
customer = Customer.resolve!(object)
|
|
9
|
+
total = object.fetch("total")
|
|
10
|
+
record = persist_snapshot(object, account_id: account_id, owner: customer, attributes: {
|
|
11
|
+
amount: Whop.minor_units(total), currency: total.fetch("currency"),
|
|
12
|
+
data: object.slice("checkout_link", "number", "due_at", "paid_at", "overdue", "membership_id")
|
|
13
|
+
})
|
|
14
|
+
Charge.sync(id, account_id: account_id) if object["status"] == "paid"
|
|
15
|
+
record
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def checkout_url = data.to_h["checkout_link"]
|
|
19
|
+
|
|
20
|
+
def issue(charge_at: nil, idempotency_key: SecureRandom.uuid)
|
|
21
|
+
verify_configuration!
|
|
22
|
+
Whop.api.invoices.issue(id: processor_id, charge_at: charge_at&.iso8601, request_options: Whop.request_options(idempotency_key: idempotency_key))
|
|
23
|
+
sync!
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
%i[void mark_uncollectible unschedule].each do |operation|
|
|
27
|
+
define_method(operation) do |idempotency_key: SecureRandom.uuid|
|
|
28
|
+
verify_configuration!
|
|
29
|
+
Whop.api.invoices.public_send(operation, id: processor_id, request_options: Whop.request_options(idempotency_key: idempotency_key))
|
|
30
|
+
sync!
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def charge(payment_method_id: nil, idempotency_key: SecureRandom.uuid)
|
|
35
|
+
verify_configuration!
|
|
36
|
+
customer.add_payment_method(payment_method_id) if payment_method_id
|
|
37
|
+
Whop.api_request(:post, "invoices/#{ERB::Util.url_encode(processor_id)}/charge",
|
|
38
|
+
body: {payment_method_id: payment_method_id}.compact, idempotency_key: idempotency_key)
|
|
39
|
+
sync!
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def deliver(idempotency_key: SecureRandom.uuid)
|
|
43
|
+
verify_configuration!
|
|
44
|
+
Whop.api.invoices.send_(id: processor_id, request_options: Whop.request_options(idempotency_key: idempotency_key))
|
|
45
|
+
sync!
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def update_details(memo: nil, due_at: nil, idempotency_key: SecureRandom.uuid)
|
|
49
|
+
verify_configuration!
|
|
50
|
+
Whop.api.invoices.update(id: processor_id, memo: memo, due_at: due_at&.iso8601,
|
|
51
|
+
request_options: Whop.request_options(idempotency_key: idempotency_key))
|
|
52
|
+
sync!
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def pdf_url
|
|
56
|
+
verify_configuration!
|
|
57
|
+
Whop.api.invoices.generate_pdf(id: processor_id, request_options: Whop.request_options).url
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
module Pay
|
|
2
|
+
module Whop
|
|
3
|
+
class Merchant < Pay::Merchant
|
|
4
|
+
has_many :transfers, class_name: "Pay::Whop::Transfer", dependent: :destroy
|
|
5
|
+
has_many :payouts, class_name: "Pay::Whop::Payout", dependent: :destroy
|
|
6
|
+
|
|
7
|
+
def self.registered?(account)
|
|
8
|
+
where(processor_id: account).any? { |merchant| merchant.configured? }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def configured?
|
|
12
|
+
data.to_h.values_at("whop_platform_id", "whop_environment") == [Whop.account_id, Whop.environment]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def verify_configuration!
|
|
16
|
+
raise IdentityError, "Merchant belongs to another platform or environment" unless configured? && processor_id.present?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create_account(email:, title:, idempotency_key: "pay-whop-merchant-#{id}", **options)
|
|
20
|
+
return account if processor_id.present?
|
|
21
|
+
raise ArgumentError, "Unknown account option" unless (options.keys - %i[country website metadata send_customer_emails]).empty?
|
|
22
|
+
result = Whop.api.accounts.create(email: email, title: title, **options, request_options: Whop.request_options(idempotency_key: idempotency_key))
|
|
23
|
+
object = Whop.api_attributes(Whop.api.accounts.retrieve(id: result.id, request_options: Whop.request_options))
|
|
24
|
+
raise IdentityError, "Account is not a child of this platform" unless object.dig("parent_account", "id") == Whop.account_id
|
|
25
|
+
with_lock do
|
|
26
|
+
raise IdentityError, "Merchant was already assigned" if processor_id.present? && processor_id != result.id
|
|
27
|
+
update!(processor_id: result.id, data: data.to_h.merge("whop_platform_id" => Whop.account_id, "whop_environment" => Whop.environment))
|
|
28
|
+
end
|
|
29
|
+
result
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def account
|
|
33
|
+
verify_configuration!
|
|
34
|
+
result = Whop.api.accounts.retrieve(id: processor_id, request_options: Whop.request_options)
|
|
35
|
+
object = Whop.api_attributes(result)
|
|
36
|
+
unless object.fetch("id") == processor_id && object.dig("parent_account", "id") == Whop.account_id
|
|
37
|
+
raise IdentityError, "Account is not a child of this platform"
|
|
38
|
+
end
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.sync(id, account_id:)
|
|
43
|
+
raise IdentityError, "Account event does not match its resource" unless [Whop.account_id, id].include?(account_id)
|
|
44
|
+
merchant = find_by!(processor_id: id)
|
|
45
|
+
object = Whop.api_attributes(merchant.account)
|
|
46
|
+
merchant.with_lock do
|
|
47
|
+
merchant.update!(data: merchant.data.to_h.merge("status" => object["status"], "capabilities" => object["capabilities"]))
|
|
48
|
+
end
|
|
49
|
+
merchant
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def account_link(refresh_url:, return_url:, use_case: "account_onboarding", type: nil, idempotency_key: SecureRandom.uuid)
|
|
53
|
+
account
|
|
54
|
+
use_case = type if type
|
|
55
|
+
raise ArgumentError, "Invalid account link use case" unless %w[account_onboarding payouts_portal].include?(use_case)
|
|
56
|
+
Whop.api.account_links.create(account_id: processor_id, refresh_url: refresh_url, return_url: return_url,
|
|
57
|
+
use_case: use_case, request_options: Whop.request_options(idempotency_key: idempotency_key))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def login_link(refresh_url:, return_url:, **options)
|
|
61
|
+
account_link(refresh_url: refresh_url, return_url: return_url, use_case: "payouts_portal", **options)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def readiness
|
|
65
|
+
object = Whop.api_attributes(account)
|
|
66
|
+
{"status" => object["status"], "capabilities" => object["capabilities"],
|
|
67
|
+
"required_actions" => Array(object["required_actions"]).map { |action| action.slice("action", "status", "blocked_capabilities", "cta") }}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def capability_active?(name)
|
|
71
|
+
readiness.fetch("capabilities", {}).to_h[name.to_s] == "active"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def onboarding_complete?(capabilities: %w[accept_card_payments])
|
|
75
|
+
state = readiness
|
|
76
|
+
state["status"] == "active" && capabilities.present? && capabilities.all? { |name| state["capabilities"].to_h[name.to_s] == "active" }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def balance
|
|
80
|
+
verify_configuration!
|
|
81
|
+
object = Whop.api_request(:get, "ledger_accounts/#{ERB::Util.url_encode(processor_id)}")
|
|
82
|
+
raise IdentityError, "Ledger belongs to another account" unless object.dig("owner", "id") == processor_id
|
|
83
|
+
object.fetch("balances").map do |balance|
|
|
84
|
+
balance.slice("currency", "balance", "pending_balance", "reserve_balance").transform_values { |value| value.is_a?(Numeric) ? BigDecimal(value.to_s) : value }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def reconcile!
|
|
89
|
+
account
|
|
90
|
+
counts = {transfers: 0, payouts: 0}
|
|
91
|
+
Whop.each_api_page("transfers", origin_id: Whop.account_id, destination_id: processor_id) do |object|
|
|
92
|
+
unless object.dig("origin", "id") == Whop.account_id && object.dig("destination", "id") == processor_id
|
|
93
|
+
raise IdentityError, "Transfer belongs to another merchant"
|
|
94
|
+
end
|
|
95
|
+
Transfer.sync(object.fetch("id"), account_id: Whop.account_id)
|
|
96
|
+
counts[:transfers] += 1
|
|
97
|
+
end
|
|
98
|
+
Whop.each_api_page("payouts", account_id: processor_id) do |object|
|
|
99
|
+
Payout.sync_object(object, merchant: self)
|
|
100
|
+
counts[:payouts] += 1
|
|
101
|
+
end
|
|
102
|
+
counts
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def customer_for(owner)
|
|
106
|
+
verify_configuration!
|
|
107
|
+
owner.with_lock do
|
|
108
|
+
existing = owner.pay_customers.where(type: "Pay::Whop::Customer", deleted_at: nil).detect { |customer| customer.account_id == processor_id && customer.data.to_h["whop_environment"] == Whop.environment }
|
|
109
|
+
existing || owner.pay_customers.create!(type: "Pay::Whop::Customer", processor: "whop", default: false,
|
|
110
|
+
data: {whop_account_id: processor_id, whop_environment: Whop.environment})
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def transfer(amount:, idempotency_key:, currency: "usd", decimals: 2, metadata: {})
|
|
115
|
+
account
|
|
116
|
+
result = Whop.api.transfers.create(type: "balance", origin_id: Whop.account_id, destination_id: processor_id,
|
|
117
|
+
amount: Whop.major_units(amount, decimals: decimals), currency: currency, metadata: metadata,
|
|
118
|
+
idempotence_key: idempotency_key, request_options: Whop.request_options(idempotency_key: idempotency_key))
|
|
119
|
+
Transfer.sync(result.id, account_id: Whop.account_id)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def whop? = true
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Pay
|
|
2
|
+
module Whop
|
|
3
|
+
class PaymentMethod < Pay::PaymentMethod
|
|
4
|
+
def self.sync(object, customer:)
|
|
5
|
+
customer.verify_configuration!
|
|
6
|
+
customer.with_lock do
|
|
7
|
+
method = customer.payment_methods.find_or_initialize_by(processor_id: object.fetch("id"))
|
|
8
|
+
method.update!(payment_method_type: object.fetch("payment_method_type"),
|
|
9
|
+
data: (object["card"] || {}).slice("brand", "last4", "exp_month", "exp_year"))
|
|
10
|
+
method
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def make_default!
|
|
15
|
+
customer.verify_configuration!
|
|
16
|
+
customer.with_lock do
|
|
17
|
+
customer.payment_methods.update_all(default: false)
|
|
18
|
+
update!(default: true)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def detach
|
|
23
|
+
customer.sync_payment_methods
|
|
24
|
+
raise IdentityError, "Payment method is no longer owned by this customer" unless self.class.exists?(id)
|
|
25
|
+
Whop.api.payment_methods.delete_payment_method(id: processor_id, request_options: Whop.request_options(idempotency_key: "pay-whop-detach-#{id}"))
|
|
26
|
+
destroy!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.sync_setup(id, account_id:)
|
|
30
|
+
object = Whop.api_attributes(Whop.api.setup_intents.retrieve(id: id, request_options: Whop.request_options))
|
|
31
|
+
raise InvalidPayload, "Unexpected setup intent ID" unless object.fetch("id") == id
|
|
32
|
+
Whop.verify_account!(object, expected: account_id)
|
|
33
|
+
return unless object["status"] == "succeeded"
|
|
34
|
+
customer = Customer.resolve!(object)
|
|
35
|
+
customer.add_payment_method(object.fetch("payment_method_id"))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def whop? = true
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Pay
|
|
2
|
+
module Whop
|
|
3
|
+
class Payout < Resource
|
|
4
|
+
def self.sync(id, account_id:)
|
|
5
|
+
Whop.validate_configuration!
|
|
6
|
+
merchant = Merchant.find_by!(processor_id: account_id) unless account_id == Whop.account_id
|
|
7
|
+
merchant&.verify_configuration!
|
|
8
|
+
Whop.each_api_page("payouts", account_id: account_id) do |object|
|
|
9
|
+
return sync_object(object, merchant: merchant, account_id: account_id) if object.fetch("id") == id || object["payout_request_id"] == id
|
|
10
|
+
end
|
|
11
|
+
raise IdentityError, "Payout is not owned by this merchant"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.sync_object(object, merchant: nil, account_id: merchant&.processor_id || Whop.account_id)
|
|
15
|
+
Whop.validate_configuration!
|
|
16
|
+
merchant&.verify_configuration!
|
|
17
|
+
unless account_id == (merchant&.processor_id || Whop.account_id)
|
|
18
|
+
raise IdentityError, "Payout account is not registered"
|
|
19
|
+
end
|
|
20
|
+
if object["account_id"].present? && object["account_id"] != account_id
|
|
21
|
+
raise IdentityError, "Payout belongs to another account"
|
|
22
|
+
end
|
|
23
|
+
attributes = {currency: object["currency"], data: object.slice("amount", "fee_amount", "net_amount", "fee_paid_by", "payout_request_id", "estimated_arrival", "status_detail", "created_at")}
|
|
24
|
+
return persist_snapshot(object, account_id: account_id, owner: merchant, attributes: attributes) if merchant
|
|
25
|
+
|
|
26
|
+
record = create_or_find_by!(processor_id: object.fetch("id"), account_id: account_id, environment: Whop.environment) do |payout|
|
|
27
|
+
payout.assign_attributes(**attributes, status: object.fetch("status"))
|
|
28
|
+
end
|
|
29
|
+
record.with_lock do
|
|
30
|
+
raise IdentityError, "Platform payout has an unexpected owner" if record.customer_id || record.merchant_id
|
|
31
|
+
return record if record.status == "reversed" && object["status"] != "reversed"
|
|
32
|
+
return record if %w[completed reversed failed denied canceled].include?(record.status) && %w[requested in_review processing].include?(object["status"])
|
|
33
|
+
record.update!(**attributes, status: object.fetch("status"))
|
|
34
|
+
end
|
|
35
|
+
record
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Pay
|
|
2
|
+
module Whop
|
|
3
|
+
class Resource < Pay::ApplicationRecord
|
|
4
|
+
self.table_name = "pay_whop_resources"
|
|
5
|
+
belongs_to :customer, class_name: "Pay::Whop::Customer", optional: true
|
|
6
|
+
belongs_to :merchant, class_name: "Pay::Whop::Merchant", optional: true
|
|
7
|
+
|
|
8
|
+
def self.persist_snapshot(object, account_id:, owner:, attributes:)
|
|
9
|
+
raise IdentityError, "Resource account is not registered" unless Whop.verify_account!({"account_id" => account_id})
|
|
10
|
+
owner.with_lock do
|
|
11
|
+
record = find_or_initialize_by(processor_id: object.fetch("id"), account_id: account_id, environment: Whop.environment)
|
|
12
|
+
time = Whop.timestamp(object["updated_at"])
|
|
13
|
+
previous = Whop.timestamp(record.data.to_h["whop_updated_at"])
|
|
14
|
+
return record if time && previous && previous > time
|
|
15
|
+
if self == Transfer && record.persisted? && %w[succeeded failed].include?(record.status)
|
|
16
|
+
return record if object["status"] == "processing"
|
|
17
|
+
raise InvalidPayload, "Conflicting final transfer state" if object["status"] != record.status
|
|
18
|
+
end
|
|
19
|
+
if self == Payout && record.persisted? && %w[completed reversed failed denied canceled].include?(record.status)
|
|
20
|
+
return record if record.status == "reversed" && object["status"] != "reversed"
|
|
21
|
+
return record if %w[requested in_review processing].include?(object["status"])
|
|
22
|
+
end
|
|
23
|
+
owner_key = owner.is_a?(Merchant) ? :merchant : :customer
|
|
24
|
+
if record.persisted? && record.public_send("#{owner_key}_id") != owner.id
|
|
25
|
+
raise IdentityError, "Resource owner changed"
|
|
26
|
+
end
|
|
27
|
+
record.update!(**attributes, owner_key => owner, :status => object.fetch("status"),
|
|
28
|
+
:data => attributes.fetch(:data, {}).merge("whop_updated_at" => time&.iso8601(6)))
|
|
29
|
+
record
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def verify_configuration!
|
|
34
|
+
raise IdentityError, "Resource belongs to another environment" unless environment == Whop.environment
|
|
35
|
+
Whop.verify_account!({"account_id" => account_id})
|
|
36
|
+
customer&.verify_configuration!
|
|
37
|
+
raise IdentityError, "Resource belongs to another customer account" if customer && customer.account_id != account_id
|
|
38
|
+
merchant&.verify_configuration!
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def sync!
|
|
42
|
+
verify_configuration!
|
|
43
|
+
self.class.sync(processor_id, account_id: account_id)
|
|
44
|
+
reload
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
module Pay
|
|
2
|
+
module Whop
|
|
3
|
+
class Subscription < Pay::Subscription
|
|
4
|
+
def self.sync(id, account_id: nil)
|
|
5
|
+
object = Whop.api_attributes(Whop.api.memberships.retrieve(id: id, request_options: Whop.request_options))
|
|
6
|
+
raise InvalidPayload, "Unexpected membership ID" unless object.fetch("id") == id
|
|
7
|
+
Whop.verify_account!(object, expected: account_id)
|
|
8
|
+
customer = Customer.resolve!(object)
|
|
9
|
+
unless object["renewal_price"].present?
|
|
10
|
+
Entitlement.sync_object(object, account_id: customer.account_id)
|
|
11
|
+
return
|
|
12
|
+
end
|
|
13
|
+
customer.with_lock do
|
|
14
|
+
record = customer.subscriptions.find_or_initialize_by(processor_id: id)
|
|
15
|
+
source_time = Whop.timestamp(object.fetch("updated_at"))
|
|
16
|
+
saved_time = Whop.timestamp(record.data.to_h["whop_updated_at"])
|
|
17
|
+
return record if saved_time && saved_time > source_time
|
|
18
|
+
status = object.fetch("status")
|
|
19
|
+
status = {"drafted" => "incomplete", "unresolved" => "incomplete", "canceling" => "active", "expired" => "canceled"}.fetch(status, status)
|
|
20
|
+
ends_at = if %w[canceled expired].include?(object["status"])
|
|
21
|
+
object["canceled_at"].presence || object["expires_at"].presence || object.fetch("updated_at")
|
|
22
|
+
elsif object["cancel_at_period_end"]
|
|
23
|
+
object.fetch("current_period_end")
|
|
24
|
+
end
|
|
25
|
+
record.update!(
|
|
26
|
+
name: record.name.presence || object.dig("metadata", "pay_subscription_name").presence || Pay.default_product_name,
|
|
27
|
+
processor_plan: object.fetch("variant_id"), quantity: 1, status: status,
|
|
28
|
+
pause_starts_at: (object["most_recent_action"] == "paused_at") ? Whop.timestamp(object["current_period_end"]) : nil,
|
|
29
|
+
current_period_start: Whop.timestamp(object["current_period_start"]),
|
|
30
|
+
current_period_end: Whop.timestamp(object["current_period_end"]),
|
|
31
|
+
trial_ends_at: (status == "trialing") ? Whop.timestamp(object["current_period_end"]) : nil,
|
|
32
|
+
ends_at: Whop.timestamp(ends_at),
|
|
33
|
+
data: record.data.to_h.merge("whop_updated_at" => source_time.iso8601(6), "manage_url" => object["manage_url"], "whop_billing_paused" => object["most_recent_action"] == "paused_at")
|
|
34
|
+
)
|
|
35
|
+
record
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def sync!(**options)
|
|
40
|
+
customer.verify_configuration!
|
|
41
|
+
self.class.sync(processor_id, **options, account_id: customer.account_id)
|
|
42
|
+
reload
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def api_record
|
|
46
|
+
customer.verify_configuration!
|
|
47
|
+
result = Whop.api.memberships.retrieve(id: processor_id, request_options: Whop.request_options)
|
|
48
|
+
customer.verify_resource!(Whop.api_attributes(result))
|
|
49
|
+
result
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def portal_url = data.to_h["manage_url"]
|
|
53
|
+
def whop? = true
|
|
54
|
+
def paused? = data.to_h["whop_billing_paused"] == true
|
|
55
|
+
|
|
56
|
+
def active?
|
|
57
|
+
return super unless paused?
|
|
58
|
+
%w[active trialing].include?(status) && current_period_end.present? && current_period_end.future? && (ends_at.nil? || ends_at.future?)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def resumable? = paused? || (canceled? && on_grace_period?)
|
|
62
|
+
|
|
63
|
+
def cancel(idempotency_key: SecureRandom.uuid)
|
|
64
|
+
cancel_membership(true, idempotency_key)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def cancel_now!(idempotency_key: SecureRandom.uuid)
|
|
68
|
+
cancel_membership(false, idempotency_key)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def resume(idempotency_key: SecureRandom.uuid)
|
|
72
|
+
return unpause(idempotency_key: idempotency_key) if paused?
|
|
73
|
+
customer.verify_configuration!
|
|
74
|
+
raise Error, "Only a scheduled cancellation can be resumed" unless resumable?
|
|
75
|
+
Whop.api.memberships.update(id: processor_id, cancel_at_period_end: false, request_options: Whop.request_options(idempotency_key: idempotency_key))
|
|
76
|
+
sync!
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def pause(until_time: nil, idempotency_key: SecureRandom.uuid)
|
|
80
|
+
customer.verify_configuration!
|
|
81
|
+
Whop.api.memberships.pause(id: processor_id, until: until_time&.iso8601,
|
|
82
|
+
request_options: Whop.request_options(idempotency_key: idempotency_key))
|
|
83
|
+
sync!
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def unpause(idempotency_key: SecureRandom.uuid)
|
|
87
|
+
customer.verify_configuration!
|
|
88
|
+
Whop.api.memberships.resume(id: processor_id, request_options: Whop.request_options(idempotency_key: idempotency_key))
|
|
89
|
+
sync!
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def reactivate(**options)
|
|
93
|
+
raise Pay::NotSupportedError, "Whop only reactivates one-time memberships; start a new checkout for an ended recurring subscription"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def extend_trial(days, idempotency_key: SecureRandom.uuid)
|
|
97
|
+
customer.verify_configuration!
|
|
98
|
+
raise ArgumentError, "Days must be between 1 and 1095" unless days.is_a?(Integer) && (1..1095).cover?(days)
|
|
99
|
+
Whop.api.memberships.extend_(id: processor_id, days: days, request_options: Whop.request_options(idempotency_key: idempotency_key))
|
|
100
|
+
sync!
|
|
101
|
+
end
|
|
102
|
+
alias_method :add_free_days, :extend_trial
|
|
103
|
+
|
|
104
|
+
def update_payment_method(payment_method_id, idempotency_key: SecureRandom.uuid)
|
|
105
|
+
customer.add_payment_method(payment_method_id)
|
|
106
|
+
Whop.api.memberships.update(id: processor_id, payment_method_id: payment_method_id,
|
|
107
|
+
request_options: Whop.request_options(idempotency_key: idempotency_key))
|
|
108
|
+
sync!
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def apply_promo_code(promo_code:, idempotency_key: SecureRandom.uuid)
|
|
112
|
+
customer.verify_configuration!
|
|
113
|
+
Whop.api.memberships.apply_promo_code(id: processor_id, promo_code: promo_code,
|
|
114
|
+
request_options: Whop.request_options(idempotency_key: idempotency_key))
|
|
115
|
+
sync!
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def latest_payment
|
|
119
|
+
api_record
|
|
120
|
+
payment = Whop.api.payments.list(account_id: customer.account_id, member_id: customer.processor_id,
|
|
121
|
+
membership_id: processor_id, order: "created_at", direction: "desc", first: 1, request_options: Whop.request_options).first
|
|
122
|
+
return unless payment
|
|
123
|
+
object = Whop.api_attributes(payment)
|
|
124
|
+
customer.verify_resource!(object)
|
|
125
|
+
raise IdentityError, "Payment belongs to another membership" unless object["membership_id"] == processor_id
|
|
126
|
+
customer.payment(object.fetch("id"))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def retry_failed_payment(payment_id: nil, idempotency_key: SecureRandom.uuid)
|
|
130
|
+
payment = payment_id ? customer.payment(payment_id) : latest_payment
|
|
131
|
+
raise Error, "No membership payment is available to retry" unless payment
|
|
132
|
+
object = Whop.api_attributes(payment)
|
|
133
|
+
raise IdentityError, "Payment belongs to another membership" unless object["membership_id"] == processor_id
|
|
134
|
+
return Charge.sync(payment.id, account_id: customer.account_id) if payment.status == "paid"
|
|
135
|
+
customer.retry_payment(payment.id, idempotency_key: idempotency_key)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def reduce_renewal_price(amount, decimals: nil, idempotency_key: SecureRandom.uuid)
|
|
139
|
+
object = Whop.api_attributes(api_record)
|
|
140
|
+
price = object.fetch("renewal_price")
|
|
141
|
+
precision = price.fetch("decimals")
|
|
142
|
+
raise ArgumentError, "Precision must match the renewal currency" if decimals && decimals != precision
|
|
143
|
+
value = Whop.major_units(amount, decimals: precision)
|
|
144
|
+
raise ArgumentError, "Renewal price can only be reduced" unless amount < Whop.minor_units(price)
|
|
145
|
+
update_renewal({renewal_price: value}, idempotency_key)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def change_billing_period(days, idempotency_key: SecureRandom.uuid)
|
|
149
|
+
raise ArgumentError, "Billing period must be between 1 and 365 days" unless days.is_a?(Integer) && (1..365).cover?(days)
|
|
150
|
+
api_record
|
|
151
|
+
update_renewal({billing_period_days: days}, idempotency_key)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def change_next_renewal(at, idempotency_key: SecureRandom.uuid)
|
|
155
|
+
raise ArgumentError, "Next renewal must be after today in UTC" unless at.respond_to?(:utc) && at.utc.to_date > Time.current.utc.to_date
|
|
156
|
+
api_record
|
|
157
|
+
update_renewal({current_period_end: at.utc.iso8601}, idempotency_key)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
%i[swap change_quantity].each do |operation|
|
|
161
|
+
define_method(operation) do |*args, **options|
|
|
162
|
+
raise Pay::NotSupportedError, "#{operation} is not supported by pay-whop yet"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def update_renewal(attributes, key)
|
|
169
|
+
Whop.api_request(:patch, "memberships/#{ERB::Util.url_encode(processor_id)}", body: attributes, idempotency_key: key)
|
|
170
|
+
sync!
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def cancel_membership(at_period_end, key)
|
|
174
|
+
customer.verify_configuration!
|
|
175
|
+
Whop.api.memberships.cancel(id: processor_id, cancel_at_period_end: at_period_end, request_options: Whop.request_options(idempotency_key: key))
|
|
176
|
+
sync!
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Pay
|
|
2
|
+
module Whop
|
|
3
|
+
class Transfer < Resource
|
|
4
|
+
def self.sync(id, account_id:)
|
|
5
|
+
object = Whop.api_attributes(Whop.api.transfers.retrieve(id: id, request_options: Whop.request_options))
|
|
6
|
+
raise InvalidPayload, "Unexpected transfer ID" unless object.fetch("id") == id
|
|
7
|
+
destination = object.fetch("destination").fetch("id")
|
|
8
|
+
unless object.dig("origin", "id") == Whop.account_id && [Whop.account_id, destination].include?(account_id)
|
|
9
|
+
raise IdentityError, "Transfer is outside this platform"
|
|
10
|
+
end
|
|
11
|
+
merchant = Merchant.find_by!(processor_id: destination)
|
|
12
|
+
merchant.verify_configuration!
|
|
13
|
+
total = object["amount"].presence
|
|
14
|
+
persist_snapshot(object, account_id: Whop.account_id, owner: merchant,
|
|
15
|
+
attributes: {amount: total && Whop.minor_units(total), currency: total && total.fetch("currency"),
|
|
16
|
+
data: {"origin_id" => Whop.account_id, "destination_id" => destination}})
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class CreatePayWhopDeliveries < ActiveRecord::Migration[7.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :pay_whop_deliveries do |t|
|
|
4
|
+
t.string :event_id, null: false
|
|
5
|
+
t.string :event_type, null: false
|
|
6
|
+
t.string :resource_id, null: false
|
|
7
|
+
t.string :account_id, null: false
|
|
8
|
+
t.string :environment, null: false
|
|
9
|
+
t.datetime :processed_at
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
add_index :pay_whop_deliveries, [:environment, :account_id, :event_id], unique: true, name: "pay_whop_delivery_identity"
|
|
13
|
+
add_index :pay_whop_deliveries, :processed_at
|
|
14
|
+
end
|
|
15
|
+
end
|