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

better error handling in stripe::subscription creation process

This commit is contained in:
Sylvain 2020-12-23 15:29:56 +01:00
parent 32b0222da5
commit f661428db2
15 changed files with 196 additions and 118 deletions

View File

@ -7,7 +7,7 @@ class API::PaymentSchedulesController < API::ApiController
def download
authorize @payment_schedule
# TODO, send_file File.join(Rails.root, @payment_schedule.file), type: 'application/pdf', disposition: 'attachment'
send_file File.join(Rails.root, @payment_schedule.file), type: 'application/pdf', disposition: 'attachment'
end
private

View File

@ -89,8 +89,8 @@ class API::PaymentsController < API::ApiController
end
render generate_payment_response(intent, res)
rescue Stripe::InvalidRequestError
render json: { error: 'no such setup intent' }, status: :unprocessable_entity
rescue Stripe::InvalidRequestError => e
render json: e, status: :unprocessable_entity
end
private

View File

@ -19,15 +19,16 @@ client.interceptors.response.use(function (response) {
});
function extractHumanReadableMessage(error: any): string {
if (error.match(/^<!DOCTYPE html>/)) {
// parse ruby error pages
const parser = new DOMParser();
const htmlDoc = parser.parseFromString(error, 'text/html');
return htmlDoc.querySelector('h2').textContent;
if (typeof error === 'string') {
if (error.match(/^<!DOCTYPE html>/)) {
// parse ruby error pages
const parser = new DOMParser();
const htmlDoc = parser.parseFromString(error, 'text/html');
return htmlDoc.querySelector('h2').textContent;
}
return error;
}
if (typeof error === 'string') return error;
// parse Rails errors (as JSON)
let message = '';
if (error instanceof Object) {

View File

@ -11,6 +11,7 @@ interface StripeFormProps {
onSuccess: (result: SetupIntent|PaymentConfirmation|any) => void,
onError: (message: string) => void,
customer: User,
operator: User,
className?: string,
paymentSchedule?: boolean,
cartItems?: CartItems
@ -20,7 +21,7 @@ interface StripeFormProps {
* A form component to collect the credit card details and to create the payment method on Stripe.
* The form validation button must be created elsewhere, using the attribute form="stripe-form".
*/
export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cartItems, customer }) => {
export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cartItems, customer, operator }) => {
const { t } = useTranslation('shared');
@ -57,7 +58,16 @@ export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onE
// we start by associating the payment method with the user
const { client_secret } = await PaymentAPI.setupIntent(customer.id);
const { setupIntent, error } = await stripe.confirmCardSetup(client_secret, {
payment_method: paymentMethod.id
payment_method: paymentMethod.id,
mandate_data: {
customer_acceptance: {
type: 'online',
online: {
ip_address: operator.ip_address,
user_agent: navigator.userAgent
}
}
}
})
if (error) {
onError(error.message);

View File

@ -178,6 +178,7 @@ const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuc
<StripeForm onSubmit={handleSubmit}
onSuccess={handleFormSuccess}
onError={handleFormError}
operator={currentUser}
className="stripe-form"
cartItems={cartItems}
customer={customer}

View File

@ -15,6 +15,7 @@ export interface User {
role: UserRole
name: string,
need_completion: boolean,
ip_address: string,
profile: {
id: number,
first_name: string,

View File

@ -63,7 +63,7 @@ class PaymentScheduleService
item.save!
end
StripeWorker.perform_async(:create_stripe_subscription, ps.id, reservation&.reservable&.stp_product_id) if payment_method == 'stripe'
StripeService.create_stripe_subscription(ps.id, reservation&.reservable&.stp_product_id) if payment_method == 'stripe'
ps
end
end

View File

@ -17,20 +17,22 @@ class Reservations::Reserve
user = User.find(user_id)
reservation.statistic_profile_id = StatisticProfile.find_by(user_id: user_id).id
reservation.pre_check
payment = if schedule
generate_schedule(reservation: reservation,
total: payment_details[:before_coupon],
operator_profile_id: operator_profile_id,
user: user,
payment_method: payment_method,
coupon_code: payment_details[:coupon])
else
generate_invoice(reservation, operator_profile_id, payment_details, payment_intent_id)
end
payment.save
WalletService.debit_user_wallet(payment, user, reservation)
reservation.post_save
ActiveRecord::Base.transaction do
reservation.pre_check
payment = if schedule
generate_schedule(reservation: reservation,
total: payment_details[:before_coupon],
operator_profile_id: operator_profile_id,
user: user,
payment_method: payment_method,
coupon_code: payment_details[:coupon])
else
generate_invoice(reservation, operator_profile_id, payment_details, payment_intent_id)
end
payment.save
WalletService.debit_user_wallet(payment, user, reservation)
reservation.post_save
end
true
end

View File

@ -0,0 +1,71 @@
# 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_id, reservable_stp_id)
payment_schedule = PaymentSchedule.find(payment_schedule_id)
first_item = payment_schedule.ordered_items.first
# subscription (recurring price)
price = create_price(first_item.details['recurring'],
payment_schedule.scheduled.plan.stp_product_id,
nil, monthly: true)
# other items (not recurring)
items = subscription_invoice_items(payment_schedule, first_item, reservable_stp_id)
stp_subscription = Stripe::Subscription.create({
customer: payment_schedule.invoicing_profile.user.stp_customer_id,
cancel_at: payment_schedule.scheduled.expiration_date.to_i,
promotion_code: payment_schedule.coupon&.code,
add_invoice_items: items,
items: [
{ price: price[:id] }
]
}, { api_key: Setting.get('stripe_secret_key') })
payment_schedule.update_attributes(stp_subscription_id: stp_subscription.id)
end
private
def subscription_invoice_items(payment_schedule, 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'],
payment_schedule.scheduled.plan.stp_product_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
end
end

View File

@ -19,22 +19,24 @@ class Subscriptions::Subscribe
def pay_and_save(subscription, payment_details: nil, payment_intent_id: nil, schedule: false, payment_method: nil)
return false if user_id.nil?
subscription.statistic_profile_id = StatisticProfile.find_by(user_id: user_id).id
subscription.init_save
user = User.find(user_id)
subscription.statistic_profile_id = StatisticProfile.find_by(user_id: user_id).id
payment = if schedule
generate_schedule(subscription: subscription,
total: payment_details[:before_coupon],
operator_profile_id: operator_profile_id,
user: user,
payment_method: payment_method,
coupon_code: payment_details[:coupon])
else
generate_invoice(subscription, operator_profile_id, payment_details, payment_intent_id)
end
payment.save
WalletService.debit_user_wallet(payment, user, subscription)
ActiveRecord::Base.transaction do
subscription.init_save
payment = if schedule
generate_schedule(subscription: subscription,
total: payment_details[:before_coupon],
operator_profile_id: operator_profile_id,
user: user,
payment_method: payment_method,
coupon_code: payment_details[:coupon])
else
generate_invoice(subscription, operator_profile_id, payment_details, payment_intent_id)
end
payment.save
WalletService.debit_user_wallet(payment, user, subscription)
end
true
end

View File

@ -4,6 +4,7 @@ json.extract! member, :id, :username, :email, :group_id
json.role member.roles.first.name
json.name member.profile.full_name
json.need_completion member.need_completion?
json.ip_address member.current_sign_in_ip.to_s
json.profile do
json.id member.profile.id

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
# Periodically checks if a PaymentScheduleItem cames to its due date.
# If this is the case
class PaymentScheduleWorker
include Sidekiq::Worker
def perform
PaymentScheduleItem.where(due_date: [DateTime.current.at_beginning_of_day, DateTime.current.end_of_day], state: 'new').each do |psi|
# the following depends on the payment method (stripe/check)
# if stripe:
# - verify the payment was successful
# - if not, alert the admins
# - if succeeded, generate the invoice
# if check:
# - alert the admins and the user that it is time to bank the check
# - generate the invoice
# finally, in any cases, update the psi.state field according to the new status
end
end
end

View File

@ -66,57 +66,4 @@ class StripeWorker
object.update_attributes(stp_product_id: product.id)
end
end
def create_stripe_subscription(payment_schedule_id, reservable_stp_id)
payment_schedule = PaymentSchedule.find(payment_schedule_id)
first_item = payment_schedule.ordered_items.first
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 = Stripe::Price.create({
unit_amount: first_item.details['adjustment'],
currency: Setting.get('stripe_currency'),
product: payment_schedule.scheduled.plan.stp_product_id,
nickname: "Price adjustment for payment schedule #{payment_schedule_id}"
}, { api_key: Setting.get('stripe_secret_key') })
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 = Stripe::Price.create({
unit_amount: first_item.details['other_items'],
currency: Setting.get('stripe_currency'),
product: reservable_stp_id,
nickname: "Reservations for payment schedule #{payment_schedule_id}"
}, { api_key: Setting.get('stripe_secret_key') })
items.push(price: p2[:id])
end
end
# subscription (recurring price)
price = Stripe::Price.create({
unit_amount: first_item.details['recurring'],
currency: Setting.get('stripe_currency'),
recurring: { interval: 'month', interval_count: 1 },
product: payment_schedule.scheduled.plan.stp_product_id
},
{ api_key: Setting.get('stripe_secret_key') })
stp_subscription = Stripe::Subscription.create({
customer: payment_schedule.invoicing_profile.user.stp_customer_id,
cancel_at: payment_schedule.scheduled.expiration_date.to_i,
promotion_code: payment_schedule.coupon&.code,
add_invoice_items: items,
items: [
{ price: price[:id] }
]
}, { api_key: Setting.get('stripe_secret_key') })
payment_schedule.update_attributes(stp_subscription_id: stp_subscription.id)
end
end

View File

@ -6,6 +6,7 @@ class CreatePaymentScheduleItems < ActiveRecord::Migration[5.2]
create_table :payment_schedule_items do |t|
t.integer :amount
t.datetime :due_date
t.string :state, default: 'new'
t.jsonb :details, default: '{}'
t.belongs_to :payment_schedule, foreign_key: true
t.belongs_to :invoice, foreign_key: true

View File

@ -78,11 +78,11 @@ CREATE FUNCTION public.fill_search_vector_for_project() RETURNS trigger
select string_agg(description, ' ') as content into step_description from project_steps where project_id = new.id;
new.search_vector :=
setweight(to_tsvector('pg_catalog.french', unaccent(coalesce(new.name, ''))), 'A') ||
setweight(to_tsvector('pg_catalog.french', unaccent(coalesce(new.tags, ''))), 'B') ||
setweight(to_tsvector('pg_catalog.french', unaccent(coalesce(new.description, ''))), 'D') ||
setweight(to_tsvector('pg_catalog.french', unaccent(coalesce(step_title.title, ''))), 'C') ||
setweight(to_tsvector('pg_catalog.french', unaccent(coalesce(step_description.content, ''))), 'D');
setweight(to_tsvector('pg_catalog.simple', unaccent(coalesce(new.name, ''))), 'A') ||
setweight(to_tsvector('pg_catalog.simple', unaccent(coalesce(new.tags, ''))), 'B') ||
setweight(to_tsvector('pg_catalog.simple', unaccent(coalesce(new.description, ''))), 'D') ||
setweight(to_tsvector('pg_catalog.simple', unaccent(coalesce(step_title.title, ''))), 'C') ||
setweight(to_tsvector('pg_catalog.simple', unaccent(coalesce(step_description.content, ''))), 'D');
return new;
end
@ -108,8 +108,8 @@ SET default_tablespace = '';
CREATE TABLE public.abuses (
id integer NOT NULL,
signaled_type character varying,
signaled_id integer,
signaled_type character varying,
first_name character varying,
last_name character varying,
email character varying,
@ -187,8 +187,8 @@ CREATE TABLE public.addresses (
locality character varying,
country character varying,
postal_code character varying,
placeable_type character varying,
placeable_id integer,
placeable_type character varying,
created_at timestamp without time zone,
updated_at timestamp without time zone
);
@ -263,8 +263,8 @@ CREATE TABLE public.ar_internal_metadata (
CREATE TABLE public.assets (
id integer NOT NULL,
viewable_type character varying,
viewable_id integer,
viewable_type character varying,
attachment character varying,
type character varying,
created_at timestamp without time zone,
@ -504,8 +504,8 @@ ALTER SEQUENCE public.coupons_id_seq OWNED BY public.coupons.id;
CREATE TABLE public.credits (
id integer NOT NULL,
creditable_type character varying,
creditable_id integer,
creditable_type character varying,
plan_id integer,
hours integer,
created_at timestamp without time zone,
@ -1046,8 +1046,8 @@ ALTER SEQUENCE public.invoice_items_id_seq OWNED BY public.invoice_items.id;
CREATE TABLE public.invoices (
id integer NOT NULL,
invoiced_type character varying,
invoiced_id integer,
invoiced_type character varying,
stp_invoice_id character varying,
total integer,
created_at timestamp without time zone,
@ -1227,15 +1227,15 @@ ALTER SEQUENCE public.machines_id_seq OWNED BY public.machines.id;
CREATE TABLE public.notifications (
id integer NOT NULL,
receiver_id integer,
attached_object_type character varying,
attached_object_id integer,
attached_object_type character varying,
notification_type_id integer,
is_read boolean DEFAULT false,
created_at timestamp without time zone,
updated_at timestamp without time zone,
receiver_type character varying,
is_send boolean DEFAULT false,
meta_data jsonb DEFAULT '"{}"'::jsonb
meta_data jsonb DEFAULT '{}'::jsonb
);
@ -1470,6 +1470,7 @@ CREATE TABLE public.payment_schedule_items (
id bigint NOT NULL,
amount integer,
due_date timestamp without time zone,
state character varying DEFAULT 'new'::character varying,
details jsonb DEFAULT '"{}"'::jsonb,
payment_schedule_id bigint,
invoice_id bigint,
@ -1657,8 +1658,8 @@ CREATE TABLE public.prices (
id integer NOT NULL,
group_id integer,
plan_id integer,
priceable_type character varying,
priceable_id integer,
priceable_type character varying,
amount integer,
created_at timestamp without time zone NOT NULL,
updated_at timestamp without time zone NOT NULL
@ -1973,8 +1974,8 @@ CREATE TABLE public.reservations (
message text,
created_at timestamp without time zone,
updated_at timestamp without time zone,
reservable_type character varying,
reservable_id integer,
reservable_type character varying,
nb_reserve_places integer,
statistic_profile_id integer
);
@ -2006,8 +2007,8 @@ ALTER SEQUENCE public.reservations_id_seq OWNED BY public.reservations.id;
CREATE TABLE public.roles (
id integer NOT NULL,
name character varying,
resource_type character varying,
resource_id integer,
resource_type character varying,
created_at timestamp without time zone,
updated_at timestamp without time zone
);
@ -2943,8 +2944,8 @@ CREATE TABLE public.users_roles (
CREATE TABLE public.wallet_transactions (
id integer NOT NULL,
wallet_id integer,
transactable_type character varying,
transactable_id integer,
transactable_type character varying,
transaction_type character varying,
amount integer,
created_at timestamp without time zone NOT NULL,
@ -4033,14 +4034,6 @@ ALTER TABLE ONLY public.roles
ADD CONSTRAINT roles_pkey PRIMARY KEY (id);
--
-- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.schema_migrations
ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version);
--
-- Name: settings settings_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@ -5105,6 +5098,29 @@ CREATE INDEX profiles_lower_unaccent_last_name_trgm_idx ON public.profiles USING
CREATE INDEX projects_search_vector_idx ON public.projects USING gin (search_vector);
--
-- Name: unique_schema_migrations; Type: INDEX; Schema: public; Owner: -
--
CREATE UNIQUE INDEX unique_schema_migrations ON public.schema_migrations USING btree (version);
--
-- Name: accounting_periods accounting_periods_del_protect; Type: RULE; Schema: public; Owner: -
--
CREATE RULE accounting_periods_del_protect AS
ON DELETE TO public.accounting_periods DO INSTEAD NOTHING;
--
-- Name: accounting_periods accounting_periods_upd_protect; Type: RULE; Schema: public; Owner: -
--
CREATE RULE accounting_periods_upd_protect AS
ON UPDATE TO public.accounting_periods DO INSTEAD NOTHING;
--
-- Name: projects projects_search_content_trigger; Type: TRIGGER; Schema: public; Owner: -
--
@ -5639,6 +5655,7 @@ INSERT INTO "schema_migrations" (version) VALUES
('20140605125131'),
('20140605142133'),
('20140605151442'),
('20140606133116'),
('20140609092700'),
('20140609092827'),
('20140610153123'),
@ -5707,12 +5724,14 @@ INSERT INTO "schema_migrations" (version) VALUES
('20150507075620'),
('20150512123546'),
('20150520132030'),
('20150520133409'),
('20150526130729'),
('20150527153312'),
('20150529113555'),
('20150601125944'),
('20150603104502'),
('20150603104658'),
('20150603133050'),
('20150604081757'),
('20150604131525'),
('20150608142234'),
@ -5794,6 +5813,7 @@ INSERT INTO "schema_migrations" (version) VALUES
('20160905142700'),
('20160906094739'),
('20160906094847'),
('20160906145713'),
('20160915105234'),
('20161123104604'),
('20170109085345'),