1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-19 13:54:25 +01:00

create payment schedules on payzen

Also: make generic the creation of products on remote gateway
Also: make generic the call to gateway specific actions
This commit is contained in:
Sylvain 2021-04-30 16:07:19 +02:00
parent 5f47624d4e
commit e3187460ea
23 changed files with 287 additions and 166 deletions

View File

@ -61,11 +61,6 @@ class API::PayzenController < API::PaymentsController
render json: e, status: :unprocessable_entity
end
def confirm_payment_schedule
# TODO
raise NotImplementedError
end
private
def on_reservation_success(order_id, details)

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
# Raised when an an error occurred with any payment gateway
class PaymentGatewayError < StandardError
end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
# Raised when an an error occurred with the PayZen payment gateway
class PayzenError < StandardError
class PayzenError < PaymentGatewayError
end

View File

@ -36,9 +36,4 @@ export default class PayzenAPI {
const res: AxiosResponse<ConfirmPaymentResponse> = await apiClient.post('/api/payzen/confirm_payment', { cart_items: cartItems, order_id: orderId });
return res?.data;
}
static async confirmSchedule(orderId: string, cartItems: CartItems): Promise<any> {
const res: AxiosResponse<any> = await apiClient.post('/api/payzen/confirm_payment_schedule', { cart_items: cartItems, order_id: orderId });
return res?.data;
}
}

View File

@ -63,7 +63,7 @@ export const PayzenForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
const transaction = event.clientAnswer.transactions[0];
if (event.clientAnswer.orderStatus === 'PAID') {
confirm(event).then((confirmation) => {
PayzenAPI.confirm(event.clientAnswer.orderDetails.orderId, cartItems).then((confirmation) => {
PayZenKR.current.removeForms().then(() => {
onSuccess(confirmation);
});
@ -77,17 +77,6 @@ export const PayzenForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
return true;
};
/**
* Ask the API to confirm the processed transaction, depending on the current transaction (schedule or not).
*/
const confirm = async (paymentAnswer: ProcessPaymentAnswer): Promise<any> => {
if (paymentSchedule) {
return await PayzenAPI.confirm(paymentAnswer.clientAnswer.orderDetails.orderId, cartItems);
} else {
return await PayzenAPI.confirmSchedule(paymentAnswer.clientAnswer.orderDetails.orderId, cartItems);
}
}
/**
* Callback triggered when the PayZen form was entirely loaded and displayed
* @see https://docs.lyra.com/fr/rest/V4.0/javascript/features/reference.html#%C3%89v%C3%A9nements

View File

@ -5,8 +5,8 @@ class Coupon < ApplicationRecord
has_many :invoices
has_many :payment_schedule
after_create :create_stripe_coupon
after_commit :delete_stripe_coupon, on: [:destroy]
after_create :create_gateway_coupon
after_commit :delete_gateway_coupon, on: [:destroy]
validates :name, presence: true
validates :code, presence: true
@ -97,12 +97,12 @@ class Coupon < ApplicationRecord
private
def create_stripe_coupon
StripeService.create_stripe_coupon(id)
def create_gateway_coupon
PaymentGatewayService.create_coupon(id)
end
def delete_stripe_coupon
StripeWorker.perform_async(:delete_stripe_coupon, code)
def delete_gateway_coupon
PaymentGatewayService.delete_coupon(id)
end
end

View File

@ -163,7 +163,7 @@ class Invoice < PaymentDocument
end
def paid_by_card?
!payment_gateway_object.nil? || %w[stripe payzen].include?(payment_method)
!payment_gateway_object.nil? && payment_method == 'card'
end
private

View File

@ -31,8 +31,8 @@ class Machine < ApplicationRecord
after_create :create_statistic_subtype
after_create :create_machine_prices
after_create :update_stripe_product
after_update :update_stripe_product, if: :saved_change_to_name?
after_create :update_gateway_product
after_update :update_gateway_product, if: :saved_change_to_name?
after_update :update_statistic_subtype, if: :saved_change_to_name?
after_destroy :remove_statistic_subtype
@ -79,7 +79,7 @@ class Machine < ApplicationRecord
private
def update_stripe_product
StripeWorker.perform_async(:create_or_update_stp_product, Machine.name, id)
def update_gateway_product
PaymentGatewayService.create_or_update_product(Machine.name, id)
end
end

View File

@ -70,10 +70,10 @@ class PaymentSchedule < PaymentDocument
payment_schedule_items
end
def post_save(setup_intent_id)
def post_save(gateway_method_id)
return unless payment_method == 'card'
StripeService.create_stripe_subscription(self, setup_intent_id)
PaymentGatewayService.new.create_subscription(self, gateway_method_id)
end
private

View File

@ -24,8 +24,8 @@ class Plan < ApplicationRecord
after_create :create_spaces_prices
after_create :create_statistic_type
after_create :set_name
after_create :update_stripe_product
after_update :update_stripe_product, if: :saved_change_to_base_name?
after_create :update_gateway_product
after_update :update_gateway_product, if: :saved_change_to_base_name?
validates :amount, :group, :base_name, presence: true
validates :interval_count, numericality: { only_integer: true, greater_than_or_equal_to: 1 }
@ -130,7 +130,7 @@ class Plan < ApplicationRecord
update_columns(name: human_readable_name)
end
def update_stripe_product
StripeWorker.perform_async(:create_or_update_stp_product, Plan.name, id)
def update_gateway_product
PaymentGatewayService.create_or_update_product(Plan.name, id)
end
end

View File

@ -28,8 +28,8 @@ class Space < ApplicationRecord
after_create :create_statistic_subtype
after_create :create_space_prices
after_create :update_stripe_product
after_update :update_stripe_product, if: :saved_change_to_name?
after_create :update_gateway_product
after_update :update_gateway_product, if: :saved_change_to_name?
after_update :update_statistic_subtype, if: :saved_change_to_name?
after_destroy :remove_statistic_subtype
@ -67,7 +67,7 @@ class Space < ApplicationRecord
private
def update_stripe_product
StripeWorker.perform_async(:create_or_update_stp_product, Space.name, id)
def update_gateway_product
PaymentGatewayService.create_or_update_product(Space.name, id)
end
end

View File

@ -30,8 +30,8 @@ class Training < ApplicationRecord
after_create :create_statistic_subtype
after_create :create_trainings_pricings
after_create :update_stripe_product
after_update :update_stripe_product, if: :saved_change_to_name?
after_create :update_gateway_product
after_update :update_gateway_product, if: :saved_change_to_name?
after_update :update_statistic_subtype, if: :saved_change_to_name?
after_destroy :remove_statistic_subtype
@ -69,7 +69,7 @@ class Training < ApplicationRecord
end
end
def update_stripe_product
StripeWorker.perform_async(:create_or_update_stp_product, Training.name, id)
def update_gateway_product
PaymentGatewayService.create_or_update_product(Training.name, id)
end
end

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
# create remote items on currently active payment gateway
class PaymentGatewayService
def initialize
@gateway = if Stripe::Helper.enabled?
require 'stripe/service'
Stripe::Service
elsif PayZen::Helper.enabled?
require 'pay_zen/service'
PayZen::Service
else
require 'payment/service'
Payment::Service
end
end
def create_subscription(payment_schedule, gateway_object_id)
@gateway.create_subscription(payment_schedule, gateway_object_id)
end
def create_coupon(coupon_id)
@gateway.create_coupon(coupon_id)
end
def delete_coupon(coupon_id)
@gateway.delete_coupon(coupon_id)
end
def create_or_update_product(klass, id)
@gateway.create_or_update_product(klass, id)
end
end

View File

@ -20,7 +20,7 @@ class PlansService
{ errors: plan.errors }
end
end
rescue Stripe::InvalidRequestError => e
rescue PaymentGatewayError => e
{ errors: e.message }
end
end

View File

@ -1,113 +0,0 @@
# frozen_string_literal: true
# Helpers and utilities for interactions with the Stripe payment gateway
class StripeService
class << self
# Create the provided PaymentSchedule on Stripe, using the Subscription API
def create_stripe_subscription(payment_schedule, setup_intent_id)
stripe_key = Setting.get('stripe_secret_key')
first_item = payment_schedule.ordered_items.first
case payment_schedule.scheduled_type
when Reservation.name
subscription = payment_schedule.scheduled.subscription
reservable_stp_id = payment_schedule.scheduled.reservable&.payment_gateway_object&.gateway_object_id
when Subscription.name
subscription = payment_schedule.scheduled
reservable_stp_id = nil
else
raise InvalidSubscriptionError
end
handle_wallet_transaction(payment_schedule)
# setup intent (associates the customer and the payment method)
intent = Stripe::SetupIntent.retrieve(setup_intent_id, api_key: stripe_key)
# subscription (recurring price)
price = create_price(first_item.details['recurring'],
subscription.plan.payment_gateway_object.gateway_object_id,
nil, monthly: true)
# other items (not recurring)
items = subscription_invoice_items(payment_schedule, subscription, first_item, reservable_stp_id)
stp_subscription = Stripe::Subscription.create({
customer: payment_schedule.invoicing_profile.user.payment_gateway_object.gateway_object_id,
cancel_at: (payment_schedule.ordered_items.last.due_date + 3.day).to_i,
add_invoice_items: items,
coupon: payment_schedule.coupon&.code,
items: [
{ price: price[:id] }
],
default_payment_method: intent[:payment_method]
}, { api_key: stripe_key })
pgo = PaymentGatewayObject.new(item: payment_schedule)
pgo.gateway_object = stp_subscription
pgo.save!
end
def create_stripe_coupon(coupon_id)
coupon = Coupon.find(coupon_id)
stp_coupon = { id: coupon.code }
if coupon.type == 'percent_off'
stp_coupon[:percent_off] = coupon.percent_off
elsif coupon.type == 'amount_off'
stp_coupon[:amount_off] = coupon.amount_off
stp_coupon[:currency] = Setting.get('stripe_currency')
end
stp_coupon[:duration] = coupon.validity_per_user == 'always' ? 'forever' : 'once'
stp_coupon[:redeem_by] = coupon.valid_until.to_i unless coupon.valid_until.nil?
stp_coupon[:max_redemptions] = coupon.max_usages unless coupon.max_usages.nil?
Stripe::Coupon.create(stp_coupon, api_key: Setting.get('stripe_secret_key'))
end
private
def subscription_invoice_items(payment_schedule, subscription, first_item, reservable_stp_id)
second_item = payment_schedule.ordered_items[1]
items = []
if first_item.amount != second_item.amount
unless first_item.details['adjustment']&.zero?
# adjustment: when dividing the price of the plan / months, sometimes it forces us to round the amount per month.
# The difference is invoiced here
p1 = create_price(first_item.details['adjustment'],
subscription.plan.payment_gateway_object.gateway_object_id,
"Price adjustment for payment schedule #{payment_schedule.id}")
items.push(price: p1[:id])
end
unless first_item.details['other_items']&.zero?
# when taking a subscription at the same time of a reservation (space, machine or training), the amount of the
# reservation is invoiced here.
p2 = create_price(first_item.details['other_items'],
reservable_stp_id,
"Reservations for payment schedule #{payment_schedule.id}")
items.push(price: p2[:id])
end
end
items
end
def create_price(amount, stp_product_id, name, monthly: false)
params = {
unit_amount: amount,
currency: Setting.get('stripe_currency'),
product: stp_product_id,
nickname: name
}
params[:recurring] = { interval: 'month', interval_count: 1 } if monthly
Stripe::Price.create(params, api_key: Setting.get('stripe_secret_key'))
end
def handle_wallet_transaction(payment_schedule)
return unless payment_schedule.wallet_amount
customer_id = payment_schedule.invoicing_profile.user.payment_gateway_object.gateway_object_id
Stripe::Customer.update(customer_id, { balance: -payment_schedule.wallet_amount }, { api_key: Setting.get('stripe_secret_key') })
end
end
end

View File

@ -18,6 +18,7 @@ class PaymentScheduleItemWorker
def check_item(psi)
# the following depends on the payment method (stripe/check)
# FIXME
if psi.payment_schedule.payment_method == 'card'
### Stripe
stripe_key = Setting.get('stripe_secret_key')

View File

@ -46,5 +46,27 @@ class PayZen::Charge < PayZen::Client
contrib: contrib,
customer: customer)
end
##
# @see https://payzen.io/fr-FR/rest/V4.0/api/playground/Charge/CreateSubscription
##
def create_subscription(amount: 0,
currency: Setting.get('payzen_currency'),
effect_date: DateTime.current.to_s,
payment_method_token: nil,
rrule: nil,
order_id: nil,
initial_amount: nil,
initial_amount_number: nil)
post('Charge/CreateSubscription,',
amount: amount,
currency: currency,
effectDate: effect_date,
paymentMethodToken: payment_method_token,
rrule: rrule,
orderId: order_id,
initialAmount: initial_amount,
initialAmountNumber: initial_amount_number)
end
end

View File

@ -14,6 +14,4 @@ class PayZen::Order < PayZen::Client
def get(order_id, operation_type: nil)
post('/Order/Get/', orderId: order_id, operationType: operation_type)
end
end

39
lib/pay_zen/service.rb Normal file
View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
require 'payment/service'
require 'pay_zen/charge'
require 'pay_zen/order'
# PayZen payement gateway
module PayZen; end
## create remote objects on PayZen
class PayZen::Service < Payment::Service
def create_subscription(payment_schedule, order_id)
first_item = payment_schedule.ordered_items.first
order = PayZen::Order.new.get(order_id: order_id, operation_type: 'VERIFICATION')
client = PayZen::Charge.new
params = {
amount: first_item.details['recurring'].to_i,
effect_date: first_item.due_date.to_s,
payment_method_token: order['answer']['transactions'].first['paymentMethodToken'],
rrule: rrule(payment_schedule),
order_id: order_id
}
unless first_item.details['adjustment']&.zero?
params[:initial_amount] = first_item.amount
params[:initial_amount_number] = 1
end
client.create_subscription(params)
end
private
def rrule(payment_schedule)
count = payment_schedule.payment_schedule_items.count
last = payment_schedule.ordered_items.last.due_date.strftime('%Y%m%d')
"RRULE:FREQ=MONTHLY;COUNT=#{count};UNTIL=#{last}"
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
require 'pay_zen/client'
# Subscription/* endpoints of the PayZen REST API
class PayZen::Subscription < PayZen::Client
def initialize(base_url: nil, username: nil, password: nil)
super(base_url: base_url, username: username, password: password)
end
##
# @see https://payzen.io/fr-FR/rest/V4.0/api/playground/Subscription/Get/
##
def get(subscription_id, payment_method_token)
post('/Subscription/Get/', subscriptionId: subscription_id, paymentMethodToken: payment_method_token)
end
end

View File

@ -14,6 +14,4 @@ class PayZen::Token < PayZen::Client
def get(payment_method_token)
post('/Token/Get/', paymentMethodToken: payment_method_token)
end
end

16
lib/payment/service.rb Normal file
View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
# Payments module
module Payment; end
# Abstract class that must be implemented by each payment gateway.
# Provides methods to create remote objects on the payment gateway
class Payment::Service
def create_subscription(_payment_schedule, _gateway_object_id); end
def create_coupon(_coupon_id); end
def delete_coupon(_coupon_id); end
def create_or_update_product(_klass, _id); end
end

126
lib/stripe/service.rb Normal file
View File

@ -0,0 +1,126 @@
# frozen_string_literal: true
require 'payment/service'
# Stripe payement gateway
module Stripe; end
## create remote objects on stripe
class Stripe::Service < Payment::Service
# Create the provided PaymentSchedule on Stripe, using the Subscription API
def create_subscription(payment_schedule, setup_intent_id)
stripe_key = Setting.get('stripe_secret_key')
first_item = payment_schedule.ordered_items.first
case payment_schedule.scheduled_type
when Reservation.name
subscription = payment_schedule.scheduled.subscription
reservable_stp_id = payment_schedule.scheduled.reservable&.payment_gateway_object&.gateway_object_id
when Subscription.name
subscription = payment_schedule.scheduled
reservable_stp_id = nil
else
raise InvalidSubscriptionError
end
handle_wallet_transaction(payment_schedule)
# setup intent (associates the customer and the payment method)
intent = Stripe::SetupIntent.retrieve(setup_intent_id, api_key: stripe_key)
# subscription (recurring price)
price = create_price(first_item.details['recurring'],
subscription.plan.payment_gateway_object.gateway_object_id,
nil, monthly: true)
# other items (not recurring)
items = subscription_invoice_items(payment_schedule, subscription, first_item, reservable_stp_id)
stp_subscription = Stripe::Subscription.create({
customer: payment_schedule.invoicing_profile.user.payment_gateway_object.gateway_object_id,
cancel_at: (payment_schedule.ordered_items.last.due_date + 3.day).to_i,
add_invoice_items: items,
coupon: payment_schedule.coupon&.code,
items: [
{ price: price[:id] }
],
default_payment_method: intent[:payment_method]
}, { api_key: stripe_key })
pgo = PaymentGatewayObject.new(item: payment_schedule)
pgo.gateway_object = stp_subscription
pgo.save!
end
def create_coupon(coupon_id)
coupon = Coupon.find(coupon_id)
stp_coupon = { id: coupon.code }
if coupon.type == 'percent_off'
stp_coupon[:percent_off] = coupon.percent_off
elsif coupon.type == 'amount_off'
stp_coupon[:amount_off] = coupon.amount_off
stp_coupon[:currency] = Setting.get('stripe_currency')
end
stp_coupon[:duration] = coupon.validity_per_user == 'always' ? 'forever' : 'once'
stp_coupon[:redeem_by] = coupon.valid_until.to_i unless coupon.valid_until.nil?
stp_coupon[:max_redemptions] = coupon.max_usages unless coupon.max_usages.nil?
Stripe::Coupon.create(stp_coupon, api_key: Setting.get('stripe_secret_key'))
end
def delete_coupon(coupon_id)
coupon = Coupon.find(coupon_id)
StripeWorker.perform_async(:delete_stripe_coupon, coupon.code)
end
def create_or_update_product(klass, id)
StripeWorker.perform_async(:create_or_update_stp_product, klass, id)
rescue Stripe::InvalidRequestError => e
raise PaymentGatewayError(e)
end
private
def subscription_invoice_items(payment_schedule, subscription, first_item, reservable_stp_id)
second_item = payment_schedule.ordered_items[1]
items = []
if first_item.amount != second_item.amount
unless first_item.details['adjustment']&.zero?
# adjustment: when dividing the price of the plan / months, sometimes it forces us to round the amount per month.
# The difference is invoiced here
p1 = create_price(first_item.details['adjustment'],
subscription.plan.payment_gateway_object.gateway_object_id,
"Price adjustment for payment schedule #{payment_schedule.id}")
items.push(price: p1[:id])
end
unless first_item.details['other_items']&.zero?
# when taking a subscription at the same time of a reservation (space, machine or training), the amount of the
# reservation is invoiced here.
p2 = create_price(first_item.details['other_items'],
reservable_stp_id,
"Reservations for payment schedule #{payment_schedule.id}")
items.push(price: p2[:id])
end
end
items
end
def create_price(amount, stp_product_id, name, monthly: false)
params = {
unit_amount: amount,
currency: Setting.get('stripe_currency'),
product: stp_product_id,
nickname: name
}
params[:recurring] = { interval: 'month', interval_count: 1 } if monthly
Stripe::Price.create(params, api_key: Setting.get('stripe_secret_key'))
end
def handle_wallet_transaction(payment_schedule)
return unless payment_schedule.wallet_amount
customer_id = payment_schedule.invoicing_profile.user.payment_gateway_object.gateway_object_id
Stripe::Customer.update(customer_id, { balance: -payment_schedule.wallet_amount }, { api_key: Setting.get('stripe_secret_key') })
end
end