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.
@@ -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,3 @@
1
+ Pay::Whop::Engine.routes.draw do
2
+ post "webhooks", to: "pay/whop/webhooks#create"
3
+ end
@@ -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