diff --git a/app/controllers/stripe/invoices_controller.rb b/app/controllers/stripe/invoices_controller.rb new file mode 100644 index 0000000..e202c68 --- /dev/null +++ b/app/controllers/stripe/invoices_controller.rb @@ -0,0 +1,11 @@ +module Stripe + class InvoicesController < ApplicationController + before_action :authenticate_user! + + def index + pagy, items = Invoices::Index.run!(user: current_user, pagy_params:) + + render json: StripeInvoiceSerializer.new(items, meta: page_info(pagy)).serializable_hash + end + end +end diff --git a/app/errors/api_error.rb b/app/errors/api_error.rb index e385f37..a6781d5 100644 --- a/app/errors/api_error.rb +++ b/app/errors/api_error.rb @@ -87,4 +87,10 @@ def initialize super(code: 1012, status: 422, message: "stripe price not found or credit_quota is invalid") end end + + class StripeDuplicateSubscriptionPriceError < ApiError + def initialize + super(code: 1013, status: 400, message: "new subscription price cannot be the same as the current one") + end + end end diff --git a/app/interactions/invoices/index.rb b/app/interactions/invoices/index.rb new file mode 100644 index 0000000..f650fef --- /dev/null +++ b/app/interactions/invoices/index.rb @@ -0,0 +1,13 @@ +module Invoices + class Index < ActiveInteraction::Base + include Pagy::Backend + + object :user + hash :pagy_params, default: {}, strip: false + + def execute + scope = user.stripe_invoices.order(created: :desc) + pagy(scope, **pagy_params.symbolize_keys) + end + end +end diff --git a/app/interactions/subscriptions/update.rb b/app/interactions/subscriptions/update.rb index 8d88f00..00db92c 100644 --- a/app/interactions/subscriptions/update.rb +++ b/app/interactions/subscriptions/update.rb @@ -8,15 +8,18 @@ class Update < ActiveInteraction::Base def execute data = user.stripe_subscriptions.effective - subscription_uid = data.subscription_uid + raise ApiError::StripeDuplicateSubscriptionPriceError if price.eql?(data.price_uid) + subscription_uid = data.subscription_uid subscription = Stripe::Subscription.retrieve(subscription_uid) subscription_item_uid = subscription.items.data[0].id + # allow_incomplete: 允许未完成支付状态(incomplete),发票创建后无需立即付款。Stripe 会尝试扣款,失败则订阅进入 incomplete + # always_invoice: 始终创建一张新的账单(invoice)来结算这次变更。区别是一定会有 invoice(即使金额是 0) Stripe::SubscriptionItem.update( subscription_item_uid, { price:, - payment_behavior: "default_incomplete", + payment_behavior: "allow_incomplete", proration_behavior: "always_invoice", } ) diff --git a/app/interactions/webhooks/subscription.rb b/app/interactions/webhooks/subscription.rb index aebe56a..d0d7e39 100644 --- a/app/interactions/webhooks/subscription.rb +++ b/app/interactions/webhooks/subscription.rb @@ -47,6 +47,8 @@ def handle_subscription_updated(obj) raise "Subscription #{obj.id} not found" unless subscription item = obj.items.data[0] + old_price_uid = subscription.price_uid + new_price_uid = item.price.id attributes = { current_period_start: item.current_period_start, @@ -59,6 +61,12 @@ def handle_subscription_updated(obj) } subscription.update!(attributes) + + # 如果 price 发生变化并且订阅为 active,则重置用户 credits + if obj.status == "active" && old_price_uid != new_price_uid + price = StripePrice.find_by(price_uid: new_price_uid) + CreditsService.new(subscription.user).reset_credits!(price.credit_quota) if price + end end def handle_subscription_deleted(obj) diff --git a/app/models/stripe_customer.rb b/app/models/stripe_customer.rb index 0b32647..4b222fb 100644 --- a/app/models/stripe_customer.rb +++ b/app/models/stripe_customer.rb @@ -1,5 +1,6 @@ class StripeCustomer < ApplicationRecord belongs_to :user + has_many :stripe_invoices, foreign_key: :customer_uid, primary_key: :customer_uid end # == Schema Information diff --git a/app/models/user.rb b/app/models/user.rb index 5777ef7..ffdd404 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,5 +1,6 @@ class User < ApplicationRecord has_many :stripe_customers + has_many :stripe_invoices, through: :stripe_customers has_many :stripe_checkout_sessions has_many :api_calls has_many :stripe_subscriptions do diff --git a/app/serializers/stripe_invoice_serializer.rb b/app/serializers/stripe_invoice_serializer.rb new file mode 100644 index 0000000..37b986c --- /dev/null +++ b/app/serializers/stripe_invoice_serializer.rb @@ -0,0 +1,6 @@ +class StripeInvoiceSerializer + include JSONAPI::Serializer + + set_id :invoice_uid + attributes :amount_due, :billing_reason, :created, :hosted_invoice_url, :status, :subscription_uid +end diff --git a/config/routes.rb b/config/routes.rb index 9d1905a..7c4100f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,6 +21,7 @@ resources :checkout_sessions, param: :session_uid, only: %i[index create destroy] resource :subscription, only: %i[show update destroy] resources :products, only: :index + resources :invoices, only: :index resources :webhooks do post :callback, on: :collection end