1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-17 06:52:27 +01:00

full stripe subscription code refacto

TODO: test
This commit is contained in:
Sylvain 2021-10-06 17:09:35 +02:00
parent d494b012d4
commit 3663f8ab86
11 changed files with 197 additions and 123 deletions

View File

@ -47,7 +47,7 @@ class API::StripeController < API::PaymentsController
res = on_payment_success(intent, cart) if intent&.status == 'succeeded'
render generate_payment_response(intent, res)
render generate_payment_response(intent, 'payment', res)
end
def online_payment_status
@ -71,15 +71,24 @@ class API::StripeController < API::PaymentsController
def create_subscription
cart = shopping_cart
intent = Stripe::Service.new.attach_method_as_default(
cart.items.each do |item|
raise InvalidSubscriptionError unless item.valid?(@items)
raise InvalidSubscriptionError unless item.to_object.errors.empty?
end
service = Stripe::Service.new
method = service.attach_method_as_default(
params[:payment_method_id],
cart.customer.payment_gateway_object.gateway_object_id
)
@res = cart.pay_schedule(intent.id, intent.class.name)
render json: @res.to_json
stp_subscription = service.subscribe(method.id, cart)
res = on_payment_success(stp_subscription, cart) if stp_subscription&.status == 'active'
render generate_payment_response(stp_subscription.latest_invoice.payment_intent, 'subscription', res, stp_subscription.id)
end
def confirm_payment_schedule
def confirm_subscription
key = Setting.get('stripe_secret_key')
subscription = Stripe::Subscription.retrieve(params[:subscription_id], api_key: key)
@ -124,7 +133,7 @@ class API::StripeController < API::PaymentsController
super(intent.id, intent.class.name, cart)
end
def generate_payment_response(intent, res = nil)
def generate_payment_response(intent, type, res = nil, stp_subscription_id = nil)
return res unless res.nil?
if intent.status == 'requires_action' && intent.next_action.type == 'use_stripe_sdk'
@ -133,7 +142,9 @@ class API::StripeController < API::PaymentsController
status: 200,
json: {
requires_action: true,
payment_intent_client_secret: intent.client_secret
payment_intent_client_secret: intent.client_secret,
type: type,
subscription_id: stp_subscription_id
}
}
elsif intent.status == 'succeeded'

View File

@ -1,6 +1,6 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { ShoppingCart, IntentConfirmation, PaymentConfirmation, UpdateCardResponse, StripeSubscription } from '../models/payment';
import { ShoppingCart, IntentConfirmation, PaymentConfirmation, UpdateCardResponse } from '../models/payment';
import { PaymentSchedule } from '../models/payment-schedule';
import { Invoice } from '../models/invoice';
@ -21,7 +21,7 @@ export default class StripeAPI {
return res?.data;
}
static async createSubscription (paymentMethodId: string, cartItems: ShoppingCart): Promise<StripeSubscription> {
static async createSubscription (paymentMethodId: string, cartItems: ShoppingCart): Promise<PaymentConfirmation|PaymentSchedule> {
const res: AxiosResponse = await apiClient.post('/api/stripe/create_subscription', {
payment_method_id: paymentMethodId,
cart_items: cartItems
@ -34,8 +34,8 @@ export default class StripeAPI {
return res?.data;
}
static async confirmPaymentSchedule (subscriptionId: string, cartItems: ShoppingCart): Promise<PaymentSchedule> {
const res: AxiosResponse<PaymentSchedule> = await apiClient.post('/api/stripe/confirm_payment_schedule', {
static async confirmSubscription (subscriptionId: string, cartItems: ShoppingCart): Promise<PaymentSchedule> {
const res: AxiosResponse<PaymentSchedule> = await apiClient.post('/api/stripe/confirm_subscription', {
subscription_id: subscriptionId,
cart_items: cartItems
});

View File

@ -2,9 +2,10 @@ import React, { FormEvent } from 'react';
import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js';
import { useTranslation } from 'react-i18next';
import { GatewayFormProps } from '../abstract-payment-modal';
import { PaymentConfirmation, StripeSubscription } from '../../../models/payment';
import { PaymentConfirmation } from '../../../models/payment';
import StripeAPI from '../../../api/stripe';
import { Invoice } from '../../../models/invoice';
import { PaymentSchedule } from '../../../models/payment-schedule';
/**
* A form component to collect the credit card details and to create the payment method on Stripe.
@ -43,36 +44,8 @@ export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
const res = await StripeAPI.confirmMethod(paymentMethod.id, cart);
await handleServerConfirmation(res);
} else {
const paymentMethodId = paymentMethod.id;
const subscription: StripeSubscription = await StripeAPI.createSubscription(paymentMethod.id, cart);
if (subscription && subscription.status === 'active') {
// Subscription is active, no customer actions required.
const res = await StripeAPI.confirmPaymentSchedule(subscription.id, cart);
onSuccess(res);
}
const paymentIntent = subscription.latest_invoice.payment_intent;
if (paymentIntent.status === 'requires_action') {
return stripe
.confirmCardPayment(paymentIntent.client_secret, {
payment_method: paymentMethodId
})
.then(async (result) => {
if (result.error) {
throw result.error;
} else {
if (result.paymentIntent.status === 'succeeded') {
const res = await StripeAPI.confirmPaymentSchedule(subscription.id, cart);
onSuccess(res);
}
}
})
.catch((error) => {
onError(error.message);
});
} else if (paymentIntent.status === 'requires_payment_method') {
onError(t('app.shared.messages.payment_card_declined'));
}
const res = await StripeAPI.createSubscription(paymentMethod.id, cart);
await handleServerConfirmation(res);
}
} catch (err) {
// catch api errors
@ -83,10 +56,10 @@ export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
/**
* Process the server response about the Strong-customer authentication (SCA)
* @param response can be a PaymentConfirmation, or an Invoice (if the payment succeeded)
* @param response can be a PaymentConfirmation, or an Invoice/PaymentSchedule (if the payment succeeded)
* @see app/controllers/api/stripe_controller.rb#confirm_payment
*/
const handleServerConfirmation = async (response: PaymentConfirmation|Invoice) => {
const handleServerConfirmation = async (response: PaymentConfirmation|Invoice|PaymentSchedule) => {
if ('error' in response) {
if (response.error.statusText) {
onError(response.error.statusText);
@ -102,8 +75,14 @@ export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
// The card action has been handled
// The PaymentIntent can be confirmed again on the server
try {
const confirmation = await StripeAPI.confirmIntent(result.paymentIntent.id, cart);
await handleServerConfirmation(confirmation);
if (response.type === 'payment') {
const confirmation = await StripeAPI.confirmIntent(result.paymentIntent.id, cart);
await handleServerConfirmation(confirmation);
}
if (response.type === 'subscription') {
const confirmation = await StripeAPI.confirmSubscription(response.subscription_id, cart);
await handleServerConfirmation(confirmation);
}
} catch (e) {
onError(e);
}

View File

@ -4,6 +4,8 @@ import { SubscriptionRequest } from './subscription';
export interface PaymentConfirmation {
requires_action?: boolean,
payment_intent_client_secret?: string,
type?: 'payment' | 'subscription',
subscription_id?: string,
success?: boolean,
error?: {
statusText: string
@ -34,14 +36,3 @@ export interface UpdateCardResponse {
updated: boolean,
error?: string
}
export interface StripeSubscription {
id: string,
status: string,
latest_invoice: {
payment_intent: {
status: string,
client_secret: string
}
}
}

View File

@ -1,3 +1,6 @@
import { PaymentIntent } from '@stripe/stripe-js';
// https://stripe.com/docs/api/tokens/object
export interface PIIToken {
id: string,
object: 'token',
@ -8,6 +11,7 @@ export interface PIIToken {
used: boolean
}
// https://stripe.com/docs/api/charges/object
export interface Charge {
id: string,
object: 'charge',
@ -50,3 +54,118 @@ export interface ListCharges {
has_more: boolean,
data: Array<Charge>
}
// https://stripe.com/docs/api/prices/object
export interface Price {
id: string,
object: 'price',
active: boolean,
billing_scheme: 'per_unit' | 'tiered',
created: Date,
currency: string,
livemode: boolean,
lookup_key: string,
metadata: Record<string, unknown>,
nickname: string,
product: string,
recurring: {
aggregate_usage: 'sum' | 'last_during_period' | 'last_ever' | 'max',
interval: 'day' | 'week' | 'month' | 'year',
interval_count: number,
usage_type: 'metered' | 'licensed'
},
tax_behavior: 'inclusive' | 'exclusive' | 'unspecified',
tiers: [
{
flat_amount: number,
flat_amount_decimal: string,
unit_amount: number,
unit_amount_decimal: string,
up_to: number
}
],
tiers_mode: 'graduated' | 'volume',
transform_quantity: {
divide_by: number,
round: 'up' | 'down'
},
type: 'one_time' | 'recurring'
unit_amount: number,
unit_amount_decimal: string
}
// https://stripe.com/docs/api/tax_rates/object
export interface TaxRate {
id: string,
object: 'tax_rate',
active: boolean,
country: string,
description: string,
display_name: string,
inclusive: boolean,
jurisdiction: string,
metadata: Record<string, unknown>,
percentage: number,
state: string,
created: Date,
livemode: boolean,
tax_type: 'vat' | 'sales_tax' | string
}
// https://stripe.com/docs/api/subscription_items/object
export interface SubscriptionItem {
id: string,
object: 'subscription_item',
billing_thresholds: {
usage_gte: number,
},
created: Date,
metadata: Record<string, unknown>,
price: Price,
quantity: number,
subscription: string;
tax_rates: Array<TaxRate>
}
// https://stripe.com/docs/api/invoices/object
export interface Invoice {
id: string,
object: 'invoice',
auto_advance: boolean,
charge: string,
collection_method: 'charge_automatically' | 'send_invoice',
currency: string,
customer: string,
description: string,
hosted_invoice_url: string,
lines: [],
metadata: Record<string, unknown>,
payment_intent: PaymentIntent,
period_end: Date,
period_start: Date,
status: 'draft' | 'open' | 'paid' | 'uncollectible' | 'void',
subscription: string,
total: number
}
// https://stripe.com/docs/api/subscriptions/object
export interface Subscription {
id: string,
object: 'subscription',
cancel_at_period_end: boolean,
current_period_end: Date,
current_period_start: Date,
customer: string,
default_payment_method: string,
items: [
{
object: 'list',
data: Array<SubscriptionItem>,
has_more: boolean,
url: string
}
]
status: 'incomplete' | 'incomplete_expired' | 'trialing' | 'active' | 'past_due' | 'canceled' | 'unpaid',
latest_invoice: Invoice
}

View File

@ -81,10 +81,6 @@ class PaymentSchedule < PaymentDocument
PaymentGatewayService.new.create_subscription(self, gateway_method_id)
end
def pay(gateway_method_id)
PaymentGatewayService.new.pay_subscription(self, gateway_method_id)
end
def render_resource
{ partial: 'api/payment_schedules/payment_schedule', locals: { payment_schedule: self } }
end

View File

@ -69,21 +69,6 @@ class ShoppingCart
{ success: success, payment: payment, errors: errors }
end
def pay_schedule(payment_id, payment_type)
price = total
objects = []
items.each do |item|
raise InvalidSubscriptionError unless item.valid?(@items)
object = item.to_object
objects.push(object)
raise InvalidSubscriptionError unless object.errors.empty?
end
payment = create_payment_document(price, objects, payment_id, payment_type)
WalletService.debit_user_wallet(payment, @customer, transaction: false)
payment.pay(payment_id)
end
private
# Save the object associated with the provided item or raise and Rollback if something wrong append.

View File

@ -23,10 +23,6 @@ class PaymentGatewayService
@gateway.create_subscription(payment_schedule, gateway_object_id)
end
def pay_subscription(payment_schedule, gateway_object_id)
@gateway.pay_subscription(payment_schedule, gateway_object_id)
end
def create_user(user_id)
@gateway.create_user(user_id)
end

View File

@ -94,6 +94,7 @@ class WalletService
##
# Subtract the amount of the payment document (Invoice|PaymentSchedule) from the customer's wallet
# @param transaction, if false: the wallet is not debited, the transaction is only simulated on the payment document
##
def self.debit_user_wallet(payment, user, transaction: true)
wallet_amount = WalletService.wallet_amount_debit(payment, user)

View File

@ -180,10 +180,10 @@ Rails.application.routes.draw do
# card payments handling
## Stripe gateway
post 'stripe/confirm_payment' => 'stripe/confirm_payment'
post 'stripe/create_subscription' => 'stripe/create_subscription'
get 'stripe/online_payment_status' => 'stripe/online_payment_status'
get 'stripe/setup_intent/:user_id' => 'stripe#setup_intent'
post 'stripe/confirm_payment_schedule' => 'stripe#confirm_payment_schedule'
post 'stripe/create_subscription' => 'stripe/create_subscription'
post 'stripe/confirm_subscription' => 'stripe#confirm_subscription'
post 'stripe/update_card' => 'stripe#update_card'
## PayZen gateway

View File

@ -8,33 +8,16 @@ 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, subscription_id)
stripe_key = Setting.get('stripe_secret_key')
def subscribe(payment_method_id, shopping_cart)
price_details = shopping_cart.total
handle_wallet_transaction(payment_schedule)
stp_subscription = Stripe::Subscription.retrieve(subscription_id, api_key: stripe_key)
pgo = PaymentGatewayObject.new(item: payment_schedule)
pgo.gateway_object = stp_subscription
pgo.save!
end
def pay_subscription(payment_schedule, payment_method_id)
stripe_key = Setting.get('stripe_secret_key')
first_item = payment_schedule.payment_schedule_items.min_by(&:due_date)
main_object = payment_schedule.payment_schedule_objects.find(&:main)
case main_object.object_type
when Reservation.name
subscription = payment_schedule.payment_schedule_objects.find { |pso| pso.object_type == Subscription.name }.object
reservable_stp_id = main_object.object.reservable&.payment_gateway_object&.gateway_object_id
when Subscription.name
subscription = main_object.object
reservable_stp_id = nil
else
raise InvalidSubscriptionError
end
payment_schedule = price_details[:schedule][:payment_schedule]
first_item = price_details[:schedule][:items].min_by(&:due_date)
subscription = shopping_cart.items.find { |item| item.class == CartItem::Subscription }
reservable_stp_id = shopping_cart.items.find { |item| item.is_a?(CartItem::Reservation) }.to_object
.reservable&.payment_gateway_object&.gateway_object_id
WalletService.debit_user_wallet(payment_schedule, payment_schedule.invoicing_profile.user, transaction: false)
handle_wallet_transaction(payment_schedule)
price = create_price(first_item.details['recurring'],
@ -44,16 +27,27 @@ class Stripe::Service < Payment::Service
items = subscription_invoice_items(payment_schedule, subscription, first_item, reservable_stp_id)
Stripe::Subscription.create({
customer: payment_schedule.invoicing_profile.user.payment_gateway_object.gateway_object_id,
cancel_at: (payment_schedule.payment_schedule_items.max_by(&:due_date).due_date + 1.month).to_i,
add_invoice_items: items,
coupon: payment_schedule.coupon&.code,
items: [
{ price: price[:id] }
],
default_payment_method: payment_method_id,
expand: %w[latest_invoice.payment_intent]
}, { api_key: stripe_key })
customer: payment_schedule.invoicing_profile.user.payment_gateway_object.gateway_object_id,
cancel_at: (payment_schedule.payment_schedule_items.max_by(&:due_date).due_date + 1.month).to_i,
add_invoice_items: items,
coupon: payment_schedule.coupon&.code,
items: [
{ price: price[:id] }
],
default_payment_method: payment_method_id,
expand: %w[latest_invoice.payment_intent]
}, { api_key: stripe_key })
end
def create_subscription(payment_schedule, subscription_id)
stripe_key = Setting.get('stripe_secret_key')
handle_wallet_transaction(payment_schedule)
stp_subscription = Stripe::Subscription.retrieve(subscription_id, api_key: stripe_key)
pgo = PaymentGatewayObject.new(item: payment_schedule)
pgo.gateway_object = stp_subscription
pgo.save!
end
def create_user(user_id)
@ -146,19 +140,21 @@ class Stripe::Service < Payment::Service
end
def attach_method_as_default(payment_method_id, customer_id)
Stripe.api_key = Setting.get('stripe_secret_key')
stripe_key = Setting.get('stripe_secret_key')
# attach the payment method to the given customer
intent = Stripe::PaymentMethod.attach(
method = Stripe::PaymentMethod.attach(
payment_method_id,
customer: customer_id
{ customer: customer_id },
{ api_key: stripe_key }
)
# then set it as the default payment method for this customer
Stripe::Customer.update(
cart.customer.payment_gateway_object.gateway_object_id,
invoice_settings: { default_payment_method: params[:payment_method_id] }
customer_id,
{ invoice_settings: { default_payment_method: payment_method_id } },
{ api_key: stripe_key }
)
intent
method
end
private