From 981cffa27df23cc92c54cb43eafea7154c62cf27 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Fri, 26 Aug 2022 20:10:21 +0200 Subject: [PATCH] pay cart with coupon code --- app/controllers/api/checkout_controller.rb | 4 ++-- app/frontend/src/javascript/api/checkout.ts | 12 +++++++----- .../javascript/components/cart/store-cart.tsx | 18 ++++++++++++++++-- .../components/coupon/coupon-input.tsx | 5 ++++- .../payment/abstract-payment-modal.tsx | 3 ++- .../local-payment/local-payment-form.tsx | 2 +- .../components/payment/payzen/payzen-form.tsx | 4 ++-- .../payment/stripe/payment-modal.tsx | 3 ++- .../components/payment/stripe/stripe-form.tsx | 4 ++-- app/frontend/src/javascript/lib/coupon.ts | 13 +++++++++++++ app/frontend/src/javascript/models/order.ts | 2 ++ app/models/coupon.rb | 4 ++-- app/models/order.rb | 3 +++ app/services/checkout/payment_service.rb | 16 +++++++++------- app/services/payments/local_service.rb | 4 ++-- app/services/payments/payment_concern.rb | 9 ++++++--- app/services/payments/payzen_service.rb | 8 ++++---- app/services/payments/stripe_service.rb | 10 +++++----- .../20220826140921_add_coupon_id_to_order.rb | 5 +++++ .../20220826175129_add_paid_total_to_order.rb | 5 +++++ db/schema.rb | 6 +++++- 21 files changed, 99 insertions(+), 41 deletions(-) create mode 100644 app/frontend/src/javascript/lib/coupon.ts create mode 100644 db/migrate/20220826140921_add_coupon_id_to_order.rb create mode 100644 db/migrate/20220826175129_add_paid_total_to_order.rb diff --git a/app/controllers/api/checkout_controller.rb b/app/controllers/api/checkout_controller.rb index 3aeb661d4..15cf28193 100644 --- a/app/controllers/api/checkout_controller.rb +++ b/app/controllers/api/checkout_controller.rb @@ -8,14 +8,14 @@ class API::CheckoutController < API::ApiController before_action :ensure_order def payment - res = Checkout::PaymentService.new.payment(@current_order, current_user, params[:payment_id]) + res = Checkout::PaymentService.new.payment(@current_order, current_user, params[:coupon_code], params[:payment_id]) render json: res rescue StandardError => e render json: e, status: :unprocessable_entity end def confirm_payment - res = Checkout::PaymentService.new.confirm_payment(@current_order, current_user, params[:payment_id]) + res = Checkout::PaymentService.new.confirm_payment(@current_order, current_user, params[:coupon_code], params[:payment_id]) render json: res rescue StandardError => e render json: e, status: :unprocessable_entity diff --git a/app/frontend/src/javascript/api/checkout.ts b/app/frontend/src/javascript/api/checkout.ts index ed9c41bb8..c41588230 100644 --- a/app/frontend/src/javascript/api/checkout.ts +++ b/app/frontend/src/javascript/api/checkout.ts @@ -1,19 +1,21 @@ import apiClient from './clients/api-client'; import { AxiosResponse } from 'axios'; -import { OrderPayment } from '../models/order'; +import { OrderPayment, Order } from '../models/order'; export default class CheckoutAPI { - static async payment (token: string, paymentId?: string): Promise { + static async payment (order: Order, paymentId?: string): Promise { const res: AxiosResponse = await apiClient.post('/api/checkout/payment', { - order_token: token, + order_token: order.token, + coupon_code: order.coupon?.code, payment_id: paymentId }); return res?.data; } - static async confirmPayment (token: string, paymentId: string): Promise { + static async confirmPayment (order: Order, paymentId: string): Promise { const res: AxiosResponse = await apiClient.post('/api/checkout/confirm_payment', { - order_token: token, + order_token: order.token, + coupon_code: order.coupon?.code, payment_id: paymentId }); return res?.data; diff --git a/app/frontend/src/javascript/components/cart/store-cart.tsx b/app/frontend/src/javascript/components/cart/store-cart.tsx index aaf540f22..d625af6c8 100644 --- a/app/frontend/src/javascript/components/cart/store-cart.tsx +++ b/app/frontend/src/javascript/components/cart/store-cart.tsx @@ -13,6 +13,8 @@ import { PaymentMethod } from '../../models/payment'; import { Order } from '../../models/order'; import { MemberSelect } from '../user/member-select'; import { CouponInput } from '../coupon/coupon-input'; +import { Coupon } from '../../models/coupon'; +import { computePriceWithCoupon } from '../../lib/coupon'; declare const Application: IApplication; @@ -103,6 +105,16 @@ const StoreCart: React.FC = ({ onError, currentUser }) => { return cart && cart.order_items_attributes.length === 0; }; + /** + * Apply coupon to current cart + */ + const applyCoupon = (coupon?: Coupon): void => { + if (coupon !== cart.coupon) { + cart.coupon = coupon; + setCart({ ...cart, coupon }); + } + }; + return (
{cart && cartIsEmpty() &&

{t('app.public.store_cart.cart_is_empty')}

} @@ -122,8 +134,10 @@ const StoreCart: React.FC = ({ onError, currentUser }) => {
))} - {cart && !cartIsEmpty() && } - {cart && !cartIsEmpty() &&

Totale: {FormatLib.price(cart.total)}

} + {cart && !cartIsEmpty() && cart.user && } + {cart && !cartIsEmpty() &&

Total produits: {FormatLib.price(cart.total)}

} + {cart && !cartIsEmpty() && cart.coupon && computePriceWithCoupon(cart.total, cart.coupon) !== cart.total &&

Coupon réduction: {FormatLib.price(-(cart.total - computePriceWithCoupon(cart.total, cart.coupon)))}

} + {cart && !cartIsEmpty() &&

Total panier: {FormatLib.price(computePriceWithCoupon(cart.total, cart.coupon))}

} {cart && !cartIsEmpty() && isPrivileged() && } {cart && !cartIsEmpty() && diff --git a/app/frontend/src/javascript/components/coupon/coupon-input.tsx b/app/frontend/src/javascript/components/coupon/coupon-input.tsx index 1e147e0ad..ea1b4f5e4 100644 --- a/app/frontend/src/javascript/components/coupon/coupon-input.tsx +++ b/app/frontend/src/javascript/components/coupon/coupon-input.tsx @@ -9,7 +9,7 @@ import { User } from '../../models/user'; interface CouponInputProps { amount: number, user?: User, - onChange?: (coupon: Coupon) => void + onChange?: (coupon?: Coupon) => void } interface Message { @@ -58,7 +58,10 @@ export const CouponInput: React.FC = ({ user, amount, onChange setCoupon(null); setLoading(false); setMessages([{ type: 'danger', message: t(`app.shared.coupon_input.unable_to_apply_the_coupon_because_${state}`) }]); + onChange(null); }); + } else { + onChange(null); } }; diff --git a/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx b/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx index 5eab0f329..d6e9c1f83 100644 --- a/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx @@ -19,6 +19,7 @@ import { ComputePriceResult } from '../../models/price'; import { Wallet } from '../../models/wallet'; import FormatLib from '../../lib/format'; import { Order } from '../../models/order'; +import { computePriceWithCoupon } from '../../lib/coupon'; export interface GatewayFormProps { onSubmit: () => void, @@ -114,7 +115,7 @@ export const AbstractPaymentModal: React.FC = ({ isOp if (order && order?.user?.id) { WalletAPI.getByUser(order.user.id).then((wallet) => { setWallet(wallet); - const p = { price: order.total, price_without_coupon: order.total }; + const p = { price: computePriceWithCoupon(order.total, order.coupon), price_without_coupon: order.total }; setPrice(p); setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(p.price)); setReady(true); diff --git a/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx b/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx index 8036a6fa8..61edf3180 100644 --- a/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx +++ b/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx @@ -89,7 +89,7 @@ export const LocalPaymentForm: React.FC = ({ onSubmit, onSucce try { let res; if (order) { - res = await CheckoutAPI.payment(order.token); + res = await CheckoutAPI.payment(order); res = res.order; } else { res = await LocalPaymentAPI.confirmPayment(cart); diff --git a/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx b/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx index fb53a57e7..09a0a9ef9 100644 --- a/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx +++ b/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx @@ -58,7 +58,7 @@ export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onE } else if (paymentSchedule) { return await PayzenAPI.chargeCreateToken(cart, customer); } else if (order) { - const res = await CheckoutAPI.payment(order.token); + const res = await CheckoutAPI.payment(order); return res.payment as CreateTokenResponse; } else { return await PayzenAPI.chargeCreatePayment(cart, customer); @@ -97,7 +97,7 @@ export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onE if (paymentSchedule) { return await PayzenAPI.confirmPaymentSchedule(event.clientAnswer.orderDetails.orderId, transaction.uuid, cart); } else if (order) { - const res = await CheckoutAPI.confirmPayment(order.token, event.clientAnswer.orderDetails.orderId); + const res = await CheckoutAPI.confirmPayment(order, event.clientAnswer.orderDetails.orderId); return res.order; } else { return await PayzenAPI.confirm(event.clientAnswer.orderDetails.orderId, cart); diff --git a/app/frontend/src/javascript/components/payment/stripe/payment-modal.tsx b/app/frontend/src/javascript/components/payment/stripe/payment-modal.tsx index f21c7c9b7..17c625343 100644 --- a/app/frontend/src/javascript/components/payment/stripe/payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/payment-modal.tsx @@ -12,6 +12,7 @@ import { CardPaymentModal } from '../card-payment-modal'; import PriceAPI from '../../../api/price'; import { ComputePriceResult } from '../../../models/price'; import { Order } from '../../../models/order'; +import { computePriceWithCoupon } from '../../../lib/coupon'; interface PaymentModalProps { isOpen: boolean, @@ -47,7 +48,7 @@ export const PaymentModal: React.FC = ({ isOpen, toggleModal, // refresh the price when the cart changes useEffect(() => { if (order) { - setPrice({ price: order.total, price_without_coupon: order.total }); + setPrice({ price: computePriceWithCoupon(order.total, order.coupon), price_without_coupon: order.total }); } else { PriceAPI.compute(cart).then(price => { setPrice(price); diff --git a/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx index 3ea6fda3b..826246764 100644 --- a/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx @@ -44,7 +44,7 @@ export const StripeForm: React.FC = ({ onSubmit, onSuccess, on try { if (!paymentSchedule) { if (order) { - const res = await CheckoutAPI.payment(order.token, paymentMethod.id); + const res = await CheckoutAPI.payment(order, paymentMethod.id); if (res.payment) { await handleServerConfirmation(res.payment as PaymentConfirmation); } else { @@ -90,7 +90,7 @@ export const StripeForm: React.FC = ({ onSubmit, onSuccess, on // The PaymentIntent can be confirmed again on the server try { if (order) { - const confirmation = await CheckoutAPI.confirmPayment(order.token, result.paymentIntent.id); + const confirmation = await CheckoutAPI.confirmPayment(order, result.paymentIntent.id); await handleServerConfirmation(confirmation.order); } else { const confirmation = await StripeAPI.confirmIntent(result.paymentIntent.id, cart); diff --git a/app/frontend/src/javascript/lib/coupon.ts b/app/frontend/src/javascript/lib/coupon.ts new file mode 100644 index 000000000..7dd8da184 --- /dev/null +++ b/app/frontend/src/javascript/lib/coupon.ts @@ -0,0 +1,13 @@ +import { Coupon } from '../models/coupon'; + +export const computePriceWithCoupon = (price: number, coupon?: Coupon): number => { + if (!coupon) { + return price; + } + if (coupon.type === 'percent_off') { + return price - (price * coupon.percent_off / 100.00); + } else if (coupon.type === 'amount_off' && price > coupon.amount_off) { + return price - coupon.amount_off; + } + return price; +}; diff --git a/app/frontend/src/javascript/models/order.ts b/app/frontend/src/javascript/models/order.ts index 80d6d239e..3beea639b 100644 --- a/app/frontend/src/javascript/models/order.ts +++ b/app/frontend/src/javascript/models/order.ts @@ -2,6 +2,7 @@ import { TDateISO } from '../typings/date-iso'; import { PaymentConfirmation } from './payment'; import { CreateTokenResponse } from './payzen'; import { User } from './user'; +import { Coupon } from './coupon'; export interface Order { id: number, @@ -13,6 +14,7 @@ export interface Order { state?: string, payment_state?: string, total?: number, + coupon?: Coupon, created_at?: TDateISO, order_items_attributes: Array<{ id: number, diff --git a/app/models/coupon.rb b/app/models/coupon.rb index 744ad35ba..623c8623e 100644 --- a/app/models/coupon.rb +++ b/app/models/coupon.rb @@ -4,6 +4,7 @@ class Coupon < ApplicationRecord has_many :invoices has_many :payment_schedule + has_many :orders after_create :create_gateway_coupon before_destroy :delete_gateway_coupon @@ -82,7 +83,7 @@ class Coupon < ApplicationRecord end def users - invoices.map(&:user) + invoices.map(&:user).concat(orders.map(&:user)).uniq(&:id) end def users_ids @@ -104,5 +105,4 @@ class Coupon < ApplicationRecord def delete_gateway_coupon PaymentGatewayService.new.delete_coupon(id) end - end diff --git a/app/models/order.rb b/app/models/order.rb index 2d1d18161..898195af6 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -4,6 +4,7 @@ class Order < PaymentDocument belongs_to :statistic_profile belongs_to :operator_profile, class_name: 'InvoicingProfile' + belongs_to :coupon has_many :order_items, dependent: :destroy has_one :payment_gateway_object, as: :item @@ -17,6 +18,8 @@ class Order < PaymentDocument before_create :add_environment + delegate :user, to: :statistic_profile + def footprint_children order_items end diff --git a/app/services/checkout/payment_service.rb b/app/services/checkout/payment_service.rb index abef6a9ae..6f6e94216 100644 --- a/app/services/checkout/payment_service.rb +++ b/app/services/checkout/payment_service.rb @@ -6,31 +6,33 @@ class Checkout::PaymentService require 'stripe/helper' include Payments::PaymentConcern - def payment(order, operator, payment_id = '') + def payment(order, operator, coupon_code, payment_id = '') raise Cart::OutStockError unless Orders::OrderService.new.in_stock?(order, 'external') raise Cart::InactiveProductError unless Orders::OrderService.new.all_products_is_active?(order) + CouponService.new.validate(coupon_code, order.statistic_profile.user) + amount = debit_amount(order) if operator.privileged? || amount.zero? - Payments::LocalService.new.payment(order) + Payments::LocalService.new.payment(order, coupon_code) elsif operator.member? if Stripe::Helper.enabled? - Payments::StripeService.new.payment(order, payment_id) + Payments::StripeService.new.payment(order, coupon_code, payment_id) elsif PayZen::Helper.enabled? - Payments::PayzenService.new.payment(order) + Payments::PayzenService.new.payment(order, coupon_code) else raise Error('Bad gateway or online payment is disabled') end end end - def confirm_payment(order, operator, payment_id = '') + def confirm_payment(order, operator, coupon_code, payment_id = '') if operator.member? if Stripe::Helper.enabled? - Payments::StripeService.new.confirm_payment(order, payment_id) + Payments::StripeService.new.confirm_payment(order, coupon_code, payment_id) elsif PayZen::Helper.enabled? - Payments::PayzenService.new.confirm_payment(order, payment_id) + Payments::PayzenService.new.confirm_payment(order, coupon_code, payment_id) else raise Error('Bad gateway or online payment is disabled') end diff --git a/app/services/payments/local_service.rb b/app/services/payments/local_service.rb index d7912b0af..43bd07d08 100644 --- a/app/services/payments/local_service.rb +++ b/app/services/payments/local_service.rb @@ -4,8 +4,8 @@ class Payments::LocalService include Payments::PaymentConcern - def payment(order) - o = payment_success(order, 'local') + def payment(order, coupon_code) + o = payment_success(order, coupon_code, 'local') { order: o } end end diff --git a/app/services/payments/payment_concern.rb b/app/services/payments/payment_concern.rb index aeb2c37b0..9148cf7ae 100644 --- a/app/services/payments/payment_concern.rb +++ b/app/services/payments/payment_concern.rb @@ -9,14 +9,17 @@ module Payments::PaymentConcern wallet_amount >= total_amount ? total_amount : wallet_amount end - def debit_amount(order) - total = order.total + def debit_amount(order, coupon_code = nil) + total = CouponService.new.apply(order.total, coupon_code, order.statistic_profile.user) wallet_debit = get_wallet_debit(order.statistic_profile.user, total) total - wallet_debit end - def payment_success(order, payment_method = '', payment_id = nil, payment_type = nil) + def payment_success(order, coupon_code, payment_method = '', payment_id = nil, payment_type = nil) ActiveRecord::Base.transaction do + order.paid_total = debit_amount(order, coupon_code) + coupon = Coupon.find_by(code: coupon_code) + order.coupon_id = coupon.id if coupon WalletService.debit_user_wallet(order, order.statistic_profile.user) order.operator_profile_id = order.statistic_profile.user.invoicing_profile.id if order.operator_profile.nil? order.payment_method = if order.total == order.wallet_amount diff --git a/app/services/payments/payzen_service.rb b/app/services/payments/payzen_service.rb index f53af196a..30e1adeac 100644 --- a/app/services/payments/payzen_service.rb +++ b/app/services/payments/payzen_service.rb @@ -8,8 +8,8 @@ class Payments::PayzenService require 'pay_zen/service' include Payments::PaymentConcern - def payment(order) - amount = debit_amount(order) + def payment(order, coupon_code) + amount = debit_amount(order, coupon_code) raise Cart::ZeroPriceError if amount.zero? @@ -23,12 +23,12 @@ class Payments::PayzenService { order: order, payment: { formToken: result['answer']['formToken'], orderId: id } } end - def confirm_payment(order, payment_id) + def confirm_payment(order, coupon_code, payment_id) client = PayZen::Order.new payzen_order = client.get(payment_id, operation_type: 'DEBIT') if payzen_order['answer']['transactions'].any? { |transaction| transaction['status'] == 'PAID' } - o = payment_success(order, 'card', payment_id, 'PayZen::Order') + o = payment_success(order, coupon_code, 'card', payment_id, 'PayZen::Order') { order: o } else order.update(payment_state: 'failed') diff --git a/app/services/payments/stripe_service.rb b/app/services/payments/stripe_service.rb index a5e03057c..c4c8a09b5 100644 --- a/app/services/payments/stripe_service.rb +++ b/app/services/payments/stripe_service.rb @@ -5,8 +5,8 @@ class Payments::StripeService require 'stripe/service' include Payments::PaymentConcern - def payment(order, payment_id) - amount = debit_amount(order) + def payment(order, coupon_code, payment_id) + amount = debit_amount(order, coupon_code) raise Cart::ZeroPriceError if amount.zero? @@ -23,7 +23,7 @@ class Payments::StripeService ) if intent&.status == 'succeeded' - o = payment_success(order, 'card', intent.id, intent.class.name) + o = payment_success(order, coupon_code, 'card', intent.id, intent.class.name) return { order: o } end @@ -33,10 +33,10 @@ class Payments::StripeService end end - def confirm_payment(order, payment_id) + def confirm_payment(order, coupon_code, payment_id) intent = Stripe::PaymentIntent.confirm(payment_id, {}, { api_key: Setting.get('stripe_secret_key') }) if intent&.status == 'succeeded' - o = payment_success(order, 'card', intent.id, intent.class.name) + o = payment_success(order, coupon_code, 'card', intent.id, intent.class.name) { order: o } else order.update(payment_state: 'failed') diff --git a/db/migrate/20220826140921_add_coupon_id_to_order.rb b/db/migrate/20220826140921_add_coupon_id_to_order.rb new file mode 100644 index 000000000..cca23c0bd --- /dev/null +++ b/db/migrate/20220826140921_add_coupon_id_to_order.rb @@ -0,0 +1,5 @@ +class AddCouponIdToOrder < ActiveRecord::Migration[5.2] + def change + add_reference :orders, :coupon, index: true, foreign_key: true + end +end diff --git a/db/migrate/20220826175129_add_paid_total_to_order.rb b/db/migrate/20220826175129_add_paid_total_to_order.rb new file mode 100644 index 000000000..5681a3ed5 --- /dev/null +++ b/db/migrate/20220826175129_add_paid_total_to_order.rb @@ -0,0 +1,5 @@ +class AddPaidTotalToOrder < ActiveRecord::Migration[5.2] + def change + add_column :orders, :paid_total, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index 0df47bcde..e9373c7c0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_08_26_133518) do +ActiveRecord::Schema.define(version: 2022_08_26_175129) do # These are extensions that must be enabled in order to support this database enable_extension "fuzzystrmatch" @@ -473,6 +473,9 @@ ActiveRecord::Schema.define(version: 2022_08_26_133518) do t.string "payment_method" t.string "footprint" t.string "environment" + t.bigint "coupon_id" + t.integer "paid_total" + t.index ["coupon_id"], name: "index_orders_on_coupon_id" t.index ["operator_profile_id"], name: "index_orders_on_operator_profile_id" t.index ["statistic_profile_id"], name: "index_orders_on_statistic_profile_id" end @@ -1167,6 +1170,7 @@ ActiveRecord::Schema.define(version: 2022_08_26_133518) do add_foreign_key "invoices", "wallet_transactions" add_foreign_key "invoicing_profiles", "users" add_foreign_key "order_items", "orders" + add_foreign_key "orders", "coupons" add_foreign_key "orders", "invoicing_profiles", column: "operator_profile_id" add_foreign_key "orders", "statistic_profiles" add_foreign_key "organizations", "invoicing_profiles"