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

Merge branch 'extend_subscription' into dev

This commit is contained in:
Sylvain 2021-10-18 11:49:28 +02:00
commit 1237870450
69 changed files with 8730 additions and 6815 deletions

View File

@ -18,6 +18,7 @@ Metrics/BlockLength:
- 'app/pdfs/pdf/*.rb'
- 'test/**/*.rb'
Metrics/ParameterLists:
Max: 6
CountKeywordArgs: false
Style/BracesAroundHashParameters:
EnforcedStyle: context_dependent

View File

@ -1,6 +1,7 @@
# Changelog Fab-manager
- Updated production documentation
- Updated SSO documentation
- Improved stripe subscription process with better error handling
- The upgrade script will check and report the ability to access the hub API
- Fix a bug: missing translations

View File

@ -84,8 +84,8 @@ class API::StripeController < API::PaymentsController
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)
res = on_payment_success(stp_subscription, cart) if %w[active not_started].include?(stp_subscription&.status)
render generate_payment_response(stp_subscription.try(:latest_invoice)&.payment_intent, 'subscription', res, stp_subscription.id)
end
def confirm_subscription
@ -97,8 +97,10 @@ class API::StripeController < API::PaymentsController
cart = shopping_cart
if subscription&.status == 'active'
res = on_payment_success(subscription.latest_invoice.payment_intent, cart)
res = on_payment_success(subscription, cart)
render generate_payment_response(subscription.latest_invoice.payment_intent, 'subscription', res)
else
render generate_payment_response(subscription.latest_invoice.payment_intent, 'subscription', nil, subscription.id)
end
rescue Stripe::InvalidRequestError => e
render json: e, status: :unprocessable_entity

View File

@ -2,28 +2,15 @@
# API Controller for resources of type Subscription
class API::SubscriptionsController < API::ApiController
before_action :set_subscription, only: %i[show edit update destroy]
before_action :set_subscription, only: %i[show payment_details]
before_action :authenticate_user!
def show
authorize @subscription
end
def update
def payment_details
authorize @subscription
free_days = params[:subscription][:free] || false
res = Subscriptions::Subscribe.new(current_user.invoicing_profile.id)
.extend_subscription(@subscription, subscription_update_params[:expired_at], free_days)
if res.is_a?(Subscription)
@subscription = res
render status: :created
elsif res
render status: :ok
else
render status: :unprocessable_entity
end
end
private
@ -32,8 +19,4 @@ class API::SubscriptionsController < API::ApiController
def set_subscription
@subscription = Subscription.find(params[:id])
end
def subscription_update_params
params.require(:subscription).permit(:expired_at)
end
end

View File

@ -0,0 +1,20 @@
import apiClient from './clients/api-client';
import { Subscription, SubscriptionPaymentDetails, UpdateSubscriptionRequest } from '../models/subscription';
import { AxiosResponse } from 'axios';
export default class SubscriptionAPI {
static async update (request: UpdateSubscriptionRequest): Promise<Subscription> {
const res: AxiosResponse<Subscription> = await apiClient.patch(`/api/subscriptions/${request.id}`, { subscription: request });
return res?.data;
}
static async get (id: number): Promise<Subscription> {
const res: AxiosResponse<Subscription> = await apiClient.get(`/api/subscriptions/${id}`);
return res?.data;
}
static async paymentsDetails (id: number): Promise<SubscriptionPaymentDetails> {
const res: AxiosResponse<SubscriptionPaymentDetails> = await apiClient.get(`/api/subscriptions/${id}/payment_details`);
return res?.data;
}
}

View File

@ -1,11 +1,8 @@
import React from 'react';
import moment from 'moment';
import { FabModal } from '../base/fab-modal';
import { useTranslation } from 'react-i18next';
import { HtmlTranslate } from '../base/html-translate';
import { IFablab } from '../../models/fablab';
declare let Fablab: IFablab;
import FormatLib from '../../lib/format';
interface PendingTrainingModalProps {
isOpen: boolean,
@ -24,9 +21,7 @@ export const PendingTrainingModal: React.FC<PendingTrainingModalProps> = ({ isOp
* Return the formatted localized date for the given date
*/
const formatDateTime = (date: Date): string => {
const day = Intl.DateTimeFormat().format(moment(date).toDate());
const time = Intl.DateTimeFormat(Fablab.intl_locale, { hour: 'numeric', minute: 'numeric' }).format(moment(date).toDate());
return t('app.logged.pending_training_modal.DATE_TIME', { DATE: day, TIME: time });
return t('app.logged.pending_training_modal.DATE_TIME', { DATE: FormatLib.date(date), TIME: FormatLib.time(date) });
};
return (

View File

@ -17,7 +17,7 @@ interface PaymentScheduleSummaryProps {
/**
* This component displays a summary of the monthly payment schedule for the current cart, with a subscription
*/
const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedule }) => {
export const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedule }) => {
const { t } = useTranslation('shared');
// is open, the modal dialog showing the full details of the payment schedule?
@ -77,6 +77,7 @@ const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedul
</div>
);
};
const PaymentScheduleSummaryWrapper: React.FC<PaymentScheduleSummaryProps> = ({ schedule }) => {
return (
<Loader>

View File

@ -353,7 +353,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
<tr>
<td className="w-35 row-header" onClick={togglePaymentScheduleDetails(p.id)}>{expandCollapseIcon(p.id)}</td>
<td className="w-200">{p.reference}</td>
<td className="w-200">{FormatLib.date(p.created_at)}</td>
<td className="w-200">{FormatLib.date(_.minBy(p.items, 'due_date').due_date)}</td>
<td className="w-120">{FormatLib.price(p.total)}</td>
{showCustomer && <td className="w-200">{p.user.name}</td>}
<td className="w-200">{downloadButton(TargetType.PaymentSchedule, p.id)}</td>

View File

@ -12,19 +12,19 @@ interface SelectScheduleProps {
show: boolean,
selected: boolean,
onChange: (selected: boolean) => void,
className: string,
className?: string,
}
/**
* This component is a switch enabling the users to choose if they want to pay by monthly schedule
* or with a one time payment
*/
const SelectSchedule: React.FC<SelectScheduleProps> = ({ show, selected, onChange, className }) => {
export const SelectSchedule: React.FC<SelectScheduleProps> = ({ show, selected, onChange, className }) => {
const { t } = useTranslation('shared');
return (
<div className="select-schedule">
{show && <div className={className}>
{show && <div className={className || ''}>
<label htmlFor="payment_schedule">{ t('app.shared.cart.monthly_payment') }</label>
<Switch checked={selected} id="payment_schedule" onChange={onChange} className="schedule-switch" />
</div>}

View File

@ -27,6 +27,7 @@ export interface GatewayFormProps {
className?: string,
paymentSchedule?: PaymentSchedule,
cart?: ShoppingCart,
updateCart?: (cart: ShoppingCart) => void,
formId: string,
}
@ -36,6 +37,7 @@ interface AbstractPaymentModalProps {
afterSuccess: (result: Invoice|PaymentSchedule) => void,
onError: (message: string) => void,
cart: ShoppingCart,
updateCart?: (cart: ShoppingCart) => void,
currentUser: User,
schedule?: PaymentSchedule,
customer: User,
@ -56,7 +58,7 @@ interface AbstractPaymentModalProps {
* This component must not be called directly but must be extended for each implemented payment gateway
* @see https://reactjs.org/docs/composition-vs-inheritance.html
*/
export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize }) => {
export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize }) => {
// customer's wallet
const [wallet, setWallet] = useState<Wallet>(null);
// server-computed price with all details
@ -205,6 +207,7 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
className={`gateway-form ${formClassName || ''}`}
formId={formId}
cart={cart}
updateCart={updateCart}
customer={customer}
paymentSchedule={schedule}>
{hasErrors() && <div className="payment-errors">

View File

@ -1,4 +1,4 @@
import React, { FormEvent, useState } from 'react';
import React, { FormEvent, useEffect, useState } from 'react';
import Select from 'react-select';
import { useTranslation } from 'react-i18next';
import { GatewayFormProps } from '../abstract-payment-modal';
@ -24,12 +24,20 @@ type selectOption = { value: scheduleMethod, label: string };
* This is intended for use by privileged users.
* The form validation button must be created elsewhere, using the attribute form={formId}.
*/
export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, customer, operator, formId }) => {
export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, updateCart, customer, operator, formId }) => {
const { t } = useTranslation('admin');
const [method, setMethod] = useState<scheduleMethod>('check');
const [onlinePaymentModal, setOnlinePaymentModal] = useState<boolean>(false);
useEffect(() => {
if (cart.payment_method === PaymentMethod.Card) {
setMethod('card');
} else {
setMethod('check');
}
}, [cart]);
/**
* Open/closes the online payment modal, used to collect card credentials when paying the payment schedule by card.
*/
@ -58,9 +66,9 @@ export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSucce
*/
const handleUpdateMethod = (option: selectOption) => {
if (option.value === 'card') {
cart.payment_method = PaymentMethod.Card;
updateCart(Object.assign({}, cart, { payment_method: PaymentMethod.Card }));
} else {
cart.payment_method = PaymentMethod.Other;
updateCart(Object.assign({}, cart, { payment_method: PaymentMethod.Other }));
}
setMethod(option.value);
};
@ -112,7 +120,7 @@ export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSucce
className="method-select"
onChange={handleUpdateMethod}
options={buildMethodOptions()}
defaultValue={methodToOption(method)} />
value={methodToOption(method)} />
{method === 'card' && <p>{t('app.admin.local_payment.card_collection_info')}</p>}
{method === 'check' && <p>{t('app.admin.local_payment.check_collection_info', { DEADLINES: paymentSchedule.items.length })}</p>}
</div>

View File

@ -19,6 +19,7 @@ interface LocalPaymentModalProps {
afterSuccess: (result: Invoice|PaymentSchedule) => void,
onError: (message: string) => void,
cart: ShoppingCart,
updateCart: (cart: ShoppingCart) => void,
currentUser: User,
schedule?: PaymentSchedule,
customer: User
@ -27,7 +28,7 @@ interface LocalPaymentModalProps {
/**
* This component enables a privileged user to confirm a local payments.
*/
const LocalPaymentModalComponent: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer }) => {
const LocalPaymentModalComponent: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer }) => {
const { t } = useTranslation('admin');
/**
@ -44,7 +45,7 @@ const LocalPaymentModalComponent: React.FC<LocalPaymentModalProps> = ({ isOpen,
/**
* Integrates the LocalPaymentForm into the parent AbstractPaymentModal
*/
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children }) => {
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, updateCart, customer, paymentSchedule, children }) => {
return (
<LocalPaymentForm onSubmit={onSubmit}
onSuccess={onSuccess}
@ -53,6 +54,7 @@ const LocalPaymentModalComponent: React.FC<LocalPaymentModalProps> = ({ isOpen,
className={className}
formId={formId}
cart={cart}
updateCart={updateCart}
customer={customer}
paymentSchedule={paymentSchedule}>
{children}
@ -70,6 +72,7 @@ const LocalPaymentModalComponent: React.FC<LocalPaymentModalProps> = ({ isOpen,
formClassName="local-payment-form"
currentUser={currentUser}
cart={cart}
updateCart={updateCart}
customer={customer}
afterSuccess={afterSuccess}
onError={onError}
@ -81,12 +84,12 @@ const LocalPaymentModalComponent: React.FC<LocalPaymentModalProps> = ({ isOpen,
);
};
export const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule, cart, customer }) => {
export const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule, cart, updateCart, customer }) => {
return (
<Loader>
<LocalPaymentModalComponent isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} onError={onError} currentUser={currentUser} schedule={schedule} cart={cart} customer={customer} />
<LocalPaymentModalComponent isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} onError={onError} currentUser={currentUser} schedule={schedule} cart={cart} updateCart={updateCart} customer={customer} />
</Loader>
);
};
Application.Components.component('localPaymentModal', react2angular(LocalPaymentModal, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer']));
Application.Components.component('localPaymentModal', react2angular(LocalPaymentModal, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'updateCart', 'customer']));

View File

@ -2,13 +2,11 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import moment from 'moment';
import _ from 'lodash';
import { IFablab } from '../../models/fablab';
import { Plan } from '../../models/plan';
import { User, UserRole } from '../../models/user';
import { Loader } from '../base/loader';
import '../../lib/i18n';
declare let Fablab: IFablab;
import FormatLib from '../../lib/format';
interface PlanCardProps {
plan: Plan,
@ -29,14 +27,14 @@ const PlanCardComponent: React.FC<PlanCardProps> = ({ plan, userId, subscribedPl
* Return the formatted localized amount of the given plan (eg. 20.5 => "20,50 €")
*/
const amount = () : string => {
return new Intl.NumberFormat(Fablab.intl_locale, { style: 'currency', currency: Fablab.intl_currency }).format(plan.amount);
return FormatLib.price(plan.amount);
};
/**
* Return the formatted localized amount, divided by the number of months (eg. 120 => "10,00 € / month")
*/
const monthlyAmount = (): string => {
const monthly = plan.amount / moment.duration(plan.interval_count, plan.interval).asMonths();
return new Intl.NumberFormat(Fablab.intl_locale, { style: 'currency', currency: Fablab.intl_currency }).format(monthly);
return FormatLib.price(monthly);
};
/**
* Return the formatted localized duration of te given plan (eg. Month/3 => "3 mois")

View File

@ -164,6 +164,7 @@ export const ProposePacksModal: React.FC<ProposePacksModalProps> = ({ isOpen, to
afterSuccess={handlePackBought}
onError={onError}
cart={cart}
updateCart={setCart}
currentUser={operator}
customer={customer} />
</div>}

View File

@ -17,6 +17,7 @@ import { Price } from '../../models/price';
import PrepaidPackAPI from '../../api/prepaid-pack';
import { PrepaidPack } from '../../models/prepaid-pack';
import { useImmer } from 'use-immer';
import FormatLib from '../../lib/format';
declare let Fablab: IFablab;
declare const Application: IApplication;
@ -63,11 +64,11 @@ const MachinesPricing: React.FC<MachinesPricingProps> = ({ onError, onSuccess })
const hourlyRate = 10;
if (type === 'hourly_rate') {
return new Intl.NumberFormat(Fablab.intl_locale, { style: 'currency', currency: Fablab.intl_currency }).format(hourlyRate);
return FormatLib.price(hourlyRate);
}
const price = (hourlyRate / 60) * EXEMPLE_DURATION;
return new Intl.NumberFormat(Fablab.intl_locale, { style: 'currency', currency: Fablab.intl_currency }).format(price);
return FormatLib.price(price);
};
/**
@ -111,7 +112,7 @@ const MachinesPricing: React.FC<MachinesPricingProps> = ({ onError, onSuccess })
<div className="machines-pricing">
<FabAlert level="warning">
<p><HtmlTranslate trKey="app.admin.machines_pricing.prices_match_machine_hours_rates_html"/></p>
<p><HtmlTranslate trKey="app.admin.machines_pricing.prices_calculated_on_hourly_rate_html" options={{ DURATION: EXEMPLE_DURATION, RATE: examplePrice('hourly_rate'), PRICE: examplePrice('final_price') }} /></p>
<p><HtmlTranslate trKey="app.admin.machines_pricing.prices_calculated_on_hourly_rate_html" options={{ DURATION: `${EXEMPLE_DURATION}`, RATE: examplePrice('hourly_rate'), PRICE: examplePrice('final_price') }} /></p>
<p>{t('app.admin.machines_pricing.you_can_override')}</p>
</FabAlert>
<table>

View File

@ -0,0 +1,126 @@
import React, { useEffect, useState } from 'react';
import { Subscription } from '../../models/subscription';
import { FabModal, ModalSize } from '../base/fab-modal';
import { useTranslation } from 'react-i18next';
import { FabAlert } from '../base/fab-alert';
import { FabInput } from '../base/fab-input';
import FormatLib from '../../lib/format';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { IApplication } from '../../models/application';
import LocalPaymentAPI from '../../api/local-payment';
import { PaymentMethod } from '../../models/payment';
declare const Application: IApplication;
interface FreeExtendModalProps {
isOpen: boolean,
toggleModal: () => void,
subscription: Subscription,
customerId: number,
onSuccess: (message: string, newExpirationDate: Date) => void,
onError: (message: string) => void,
}
/**
* Modal dialog shown to extend the current subscription of a customer, for free
*/
const FreeExtendModal: React.FC<FreeExtendModalProps> = ({ isOpen, toggleModal, subscription, customerId, onError, onSuccess }) => {
// we do not render the modal if the subscription was not provided
if (!subscription) return null;
const { t } = useTranslation('admin');
const [expirationDate, setExpirationDate] = useState<Date>(new Date(subscription.expired_at));
const [freeDays, setFreeDays] = useState<number>(0);
// we update the number of free days when the new expiration date is updated
useEffect(() => {
if (!expirationDate || !subscription.expired_at) {
setFreeDays(0);
}
// 86400000 = 1000 * 3600 * 24 = number of ms per day
setFreeDays(Math.ceil((expirationDate.getTime() - new Date(subscription.expired_at).getTime()) / 86400000));
}, [expirationDate]);
/**
* Return the formatted localized date for the given date
*/
const formatDateTime = (date: Date): string => {
return t('app.admin.free_extend_modal.DATE_TIME', { DATE: FormatLib.date(date), TIME: FormatLib.time(date) });
};
/**
* Return the given date formatted for the HTML input-date
*/
const formatDefaultDate = (date: Date): string => {
return date.toISOString().substr(0, 10);
};
/**
* Parse the given date and record it as the new expiration date of the subscription
*/
const handleDateUpdate = (date: string): void => {
setExpirationDate(new Date(Date.parse(date)));
};
/**
* Callback triggered when the user validates the free extent of the subscription
*/
const handleConfirmExtend = (): void => {
LocalPaymentAPI.confirmPayment({
customer_id: customerId,
payment_method: PaymentMethod.Other,
items: [
{
free_extension: {
end_at: expirationDate
}
}
]
}).then(() => {
onSuccess(t('app.admin.free_extend_modal.extend_success'), expirationDate);
toggleModal();
}).catch(err => onError(err));
};
return (
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
width={ModalSize.large}
className="free-extend-modal"
title={t('app.admin.free_extend_modal.extend_subscription')}
confirmButton={t('app.admin.free_extend_modal.extend')}
onConfirm={handleConfirmExtend}
closeButton>
<FabAlert level="danger" className="conditions">
<p>{t('app.admin.free_extend_modal.offer_free_days_infos')}</p>
<p>{t('app.admin.free_extend_modal.credits_will_remain_unchanged')}</p>
</FabAlert>
<form className="configuration-form">
<label htmlFor="current_expiration">{t('app.admin.free_extend_modal.current_expiration')}</label>
<FabInput id="current_expiration"
defaultValue={formatDateTime(subscription.expired_at)}
readOnly />
<label htmlFor="new_expiration">{t('app.admin.free_extend_modal.new_expiration_date')}</label>
<FabInput id="new_expiration"
type="date"
defaultValue={formatDefaultDate(expirationDate)}
onChange={handleDateUpdate} />
<label htmlFor="free_days">{t('app.admin.free_extend_modal.number_of_free_days')}</label>
<input id="free_days" className="free-days" value={freeDays} readOnly />
</form>
</FabModal>
);
};
const FreeExtendModalWrapper: React.FC<FreeExtendModalProps> = ({ toggleModal, subscription, customerId, isOpen, onSuccess, onError }) => {
return (
<Loader>
<FreeExtendModal toggleModal={toggleModal} subscription={subscription} customerId={customerId} isOpen={isOpen} onError={onError} onSuccess={onSuccess} />
</Loader>
);
};
Application.Components.component('freeExtendModal', react2angular(FreeExtendModalWrapper, ['toggleModal', 'subscription', 'customerId', 'isOpen', 'onError', 'onSuccess']));

View File

@ -0,0 +1,164 @@
import React, { useEffect, useState } from 'react';
import { Subscription } from '../../models/subscription';
import { FabModal, ModalSize } from '../base/fab-modal';
import { useTranslation } from 'react-i18next';
import { FabAlert } from '../base/fab-alert';
import { FabInput } from '../base/fab-input';
import FormatLib from '../../lib/format';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { IApplication } from '../../models/application';
import { PaymentMethod, ShoppingCart } from '../../models/payment';
import moment from 'moment';
import { SelectSchedule } from '../payment-schedule/select-schedule';
import SubscriptionAPI from '../../api/subscription';
import PriceAPI from '../../api/price';
import { ComputePriceResult } from '../../models/price';
import { PaymentScheduleSummary } from '../payment-schedule/payment-schedule-summary';
import { PaymentSchedule } from '../../models/payment-schedule';
import { LocalPaymentModal } from '../payment/local-payment/local-payment-modal';
import { User } from '../../models/user';
declare const Application: IApplication;
interface RenewModalProps {
isOpen: boolean,
toggleModal: () => void,
subscription?: Subscription,
customer: User,
operator: User,
onSuccess: (message: string, newExpirationDate: Date) => void,
onError: (message: string) => void,
}
/**
* Modal dialog shown to renew the current subscription of a customer, for free
*/
const RenewModal: React.FC<RenewModalProps> = ({ isOpen, toggleModal, subscription, customer, operator, onError, onSuccess }) => {
// we do not render the modal if the subscription was not provided
if (!subscription) return null;
const { t } = useTranslation('admin');
const [expirationDate, setExpirationDate] = useState<Date>(new Date());
const [localPaymentModal, setLocalPaymentModal] = useState<boolean>(false);
const [cart, setCart] = useState<ShoppingCart>(null);
const [price, setPrice] = useState<ComputePriceResult>(null);
const [scheduleRequired, setScheduleRequired] = useState<boolean>(false);
// on init, we compute the new expiration date
useEffect(() => {
setExpirationDate(moment(subscription.expired_at)
.add(subscription.plan.interval_count, subscription.plan.interval)
.toDate());
SubscriptionAPI.paymentsDetails(subscription.id)
.then(res => setScheduleRequired(res.payment_schedule))
.catch(err => onError(err));
}, []);
// when the payment schedule is toggled (requested/ignored), we update the cart accordingly
useEffect(() => {
setCart({
customer_id: customer.id,
items: [{
subscription: {
plan_id: subscription.plan.id,
start_at: subscription.expired_at
}
}],
payment_method: PaymentMethod.Other,
payment_schedule: scheduleRequired
});
}, [scheduleRequired]);
// when the cart is updated, re-compute the price and the payment schedule
useEffect(() => {
if (!cart) return;
PriceAPI.compute(cart)
.then(res => setPrice(res))
.catch(err => onError(err));
}, [cart]);
/**
* Return the formatted localized date for the given date
*/
const formatDateTime = (date: Date): string => {
return t('app.admin.free_extend_modal.DATE_TIME', { DATE: FormatLib.date(date), TIME: FormatLib.time(date) });
};
/**
* Callback triggered when the payment of the subscription renewal was successful
*/
const onPaymentSuccess = (): void => {
onSuccess(t('app.admin.renew_subscription_modal.renew_success'), expirationDate);
toggleModal();
};
/**
* Open/closes the local payment modal
*/
const toggleLocalPaymentModal = (): void => {
setLocalPaymentModal(!localPaymentModal);
};
return (
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
width={ModalSize.large}
className="renew-modal"
title={t('app.admin.renew_subscription_modal.renew_subscription')}
confirmButton={t('app.admin.renew_subscription_modal.renew')}
onConfirm={toggleLocalPaymentModal}
closeButton>
<FabAlert level="danger" className="conditions">
<p>{t('app.admin.renew_subscription_modal.renew_subscription_info')}</p>
<p>{t('app.admin.renew_subscription_modal.credits_will_be_reset')}</p>
</FabAlert>
<div className="form-and-payment">
<form className="configuration-form">
<label htmlFor="current_expiration">{t('app.admin.renew_subscription_modal.current_expiration')}</label>
<FabInput id="current_expiration"
defaultValue={formatDateTime(subscription.expired_at)}
readOnly />
<label htmlFor="new_start">{t('app.admin.renew_subscription_modal.new_start')}</label>
<FabInput id="new_start"
defaultValue={formatDateTime(subscription.expired_at)}
readOnly />
<label htmlFor="new_expiration">{t('app.admin.renew_subscription_modal.new_expiration_date')}</label>
<FabInput id="new_expiration"
defaultValue={formatDateTime(expirationDate)}
readOnly/>
</form>
<div className="payment">
{subscription.plan.monthly_payment && <SelectSchedule show selected={scheduleRequired} onChange={setScheduleRequired} />}
{price?.schedule && <PaymentScheduleSummary schedule={price.schedule as PaymentSchedule} />}
{price && !price?.schedule && <div className="one-go-payment">
<h4>{t('app.admin.renew_subscription_modal.pay_in_one_go')}</h4>
<span>{FormatLib.price(price.price)}</span>
</div>}
</div>
</div>
<LocalPaymentModal isOpen={localPaymentModal}
toggleModal={toggleLocalPaymentModal}
afterSuccess={onPaymentSuccess}
onError={onError}
cart={cart}
updateCart={setCart}
currentUser={operator}
customer={customer}
schedule={price?.schedule as PaymentSchedule} />
</FabModal>
);
};
const RenewModalWrapper: React.FC<RenewModalProps> = ({ toggleModal, subscription, customer, operator, isOpen, onSuccess, onError }) => {
return (
<Loader>
<RenewModal toggleModal={toggleModal} subscription={subscription} customer={customer} operator={operator} isOpen={isOpen} onError={onError} onSuccess={onSuccess} />
</Loader>
);
};
Application.Components.component('renewModal', react2angular(RenewModalWrapper, ['toggleModal', 'subscription', 'customer', 'operator', 'isOpen', 'onError', 'onSuccess']));

View File

@ -705,6 +705,12 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
// current active authentication provider
$scope.activeProvider = activeProviderPromise;
// modal dialog to extend the current subscription for free
$scope.isOpenFreeExtendModal = false;
// modal dialog to renew the current subscription
$scope.isOpenRenewModal = false;
/**
* Open a modal dialog asking for confirmation to change the role of the given user
* @returns {*}
@ -753,53 +759,38 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
};
/**
* Open a modal dialog, allowing the admin to extend the current user's subscription (freely or not)
* @param subscription {Object} User's subscription object
* @param free {boolean} True if the extent is offered, false otherwise
* Opens/closes the modal dialog to freely extend the subscription
*/
$scope.updateSubscriptionModal = function (subscription, free) {
const modalInstance = $uibModal.open({
animation: true,
templateUrl: '/admin/subscriptions/expired_at_modal.html',
size: 'lg',
controller: ['$scope', '$uibModalInstance', 'Subscription', function ($scope, $uibModalInstance, Subscription) {
$scope.new_expired_at = angular.copy(subscription.expired_at);
$scope.free = free;
$scope.datePicker = {
opened: false,
format: Fablab.uibDateFormat,
options: {
startingDay: Fablab.weekStartingDay
},
minDate: new Date()
};
$scope.toggleFreeExtendModal = () => {
setTimeout(() => {
$scope.isOpenFreeExtendModal = !$scope.isOpenFreeExtendModal;
$scope.$apply();
}, 50);
};
$scope.openDatePicker = function (ev) {
ev.preventDefault();
ev.stopPropagation();
return $scope.datePicker.opened = true;
};
/**
* Opens/closes the modal dialog to renew the subscription (with payment)
*/
$scope.toggleRenewModal = () => {
setTimeout(() => {
$scope.isOpenRenewModal = !$scope.isOpenRenewModal;
$scope.$apply();
}, 50);
};
$scope.ok = function () {
Subscription.update(
{ id: subscription.id },
{ subscription: { expired_at: $scope.new_expired_at, free } },
function (_subscription) {
growl.success(_t('app.admin.members_edit.you_successfully_changed_the_expiration_date_of_the_user_s_subscription'));
return $uibModalInstance.close(_subscription);
},
function (error) {
growl.error(_t('app.admin.members_edit.a_problem_occurred_while_saving_the_date'));
console.error(error);
}
);
};
/**
* Callback triggered if the subscription was successfully extended
*/
$scope.onExtendSuccess = (message, newExpirationDate) => {
growl.success(message);
$scope.subscription.expired_at = newExpirationDate;
};
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
}]
});
// once the form was validated successfully ...
return modalInstance.result.then(function (subscription) { $scope.subscription.expired_at = subscription.expired_at; });
/**
* Callback triggered in case of error
*/
$scope.onError = (message) => {
growl.error(message);
};
/**

View File

@ -378,6 +378,17 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
growl.error(message);
};
/**
* Callback triggered when a child component (LocalPaymentModal) requires to update the cart content
* @param cart {ShoppingCart}
*/
$scope.updateCart = (cart) => {
setTimeout(() => {
$scope.localPayment.cartItems = cart;
$scope.$apply();
}, 50);
};
/* PRIVATE SCOPE */
/**

View File

@ -11,6 +11,13 @@ export default class FormatLib {
return Intl.DateTimeFormat().format(moment(date).toDate());
}
/**
* Return the formatted localized time for the given date
*/
static time = (date: Date): string => {
return Intl.DateTimeFormat(Fablab.intl_locale, { hour: 'numeric', minute: 'numeric' }).format(moment(date).toDate());
};
/**
* Return the formatted localized amount for the given price (eg. 20.5 => "20,50 €")
*/

View File

@ -21,7 +21,10 @@ export enum PaymentMethod {
Other = ''
}
export type CartItem = { reservation: Reservation }|{ subscription: SubscriptionRequest }|{ prepaid_pack: { id: number } };
export type CartItem = { reservation: Reservation }|
{ subscription: SubscriptionRequest }|
{ prepaid_pack: { id: number } }|
{ free_extension: { end_at: Date } };
export interface ShoppingCart {
customer_id: number,

View File

@ -5,10 +5,21 @@ export interface Subscription {
plan_id: number,
expired_at: Date,
canceled_at?: Date,
stripe: boolean,
plan: Plan
}
export interface SubscriptionRequest {
plan_id: number
plan_id: number,
start_at?: Date
}
export interface UpdateSubscriptionRequest {
id: number,
expired_at: Date,
free: boolean
}
export interface SubscriptionPaymentDetails {
payment_schedule: boolean,
card: boolean
}

View File

@ -3,8 +3,9 @@
Application.Services.factory('Subscription', ['$resource', function ($resource) {
return $resource('/api/subscriptions/:id',
{ id: '@id' }, {
update: {
method: 'PUT'
payment_details: {
url: '/api/subscriptions/:id/payment_details',
method: 'GET'
}
}
);

View File

@ -65,5 +65,7 @@
@import "modules/pricing/edit-pack";
@import "modules/prepaid-packs/propose-packs-modal";
@import "modules/prepaid-packs/packs-summary";
@import "modules/subscriptions/free-extend-modal";
@import "modules/subscriptions/renew-modal";
@import "app.responsive";

View File

@ -0,0 +1,18 @@
.free-extend-modal {
.fab-modal-content {
padding: 30px;
.configuration-form {
padding: 15px;
.input-wrapper {
display: block;
}
.free-days {
display: block;
@extend .fab-input--input;
}
}
}
}

View File

@ -0,0 +1,28 @@
.renew-modal {
.fab-modal-content {
padding: 30px;
.form-and-payment {
display: flex;
.configuration-form {
padding-right: 15px;
.input-wrapper {
display: block;
}
}
.payment {
border: 1px solid #ccc;
border-radius: 2px;
padding: 10px;
width: 50%;
.one-go-payment {
text-align: center;
}
}
}
}
}

View File

@ -77,8 +77,23 @@
{{ 'app.admin.members_edit.price_' | translate }} {{ subscription.plan.amount | currency}}
</p>
<div ng-hide="user.id === currentUser.id">
<button class="btn btn-default" ng-click="updateSubscriptionModal(subscription, true)" translate>{{ 'app.admin.members_edit.offer_free_days' }}</button>
<button class="btn btn-default" ng-click="updateSubscriptionModal(subscription, false)" translate>{{ 'app.admin.members_edit.extend_subscription' }}</button>
<button class="btn btn-default" ng-click="toggleFreeExtendModal()" translate>{{ 'app.admin.members_edit.offer_free_days' }}</button>
<button class="btn btn-default" ng-click="toggleRenewModal()" translate>{{ 'app.admin.members_edit.renew_subscription' }}</button>
<free-extend-modal is-open="isOpenFreeExtendModal"
toggle-modal="toggleFreeExtendModal"
subscription="subscription"
customer-id="user.id"
on-error="onError"
on-success="onExtendSuccess">
</free-extend-modal>
<renew-modal is-open="isOpenRenewModal"
toggle-modal="toggleRenewModal"
subscription="subscription"
customer="user"
operator="currentUser"
on-error="onError"
on-success="onExtendSuccess">
</renew-modal>
</div>
<p class="alert alert-info" ng-show="user.id === currentUser.id" translate>
{{ 'app.admin.members_edit.cannot_extend_own_subscription' }}

View File

@ -1,36 +0,0 @@
<div class="modal-header">
<h3 class="modal-title" translate>{{ 'app.admin.members_edit.expiration_date' }}</h3>
</div>
<div class="modal-body m-lg">
<div class="alert alert-danger">
<div ng-show="free">
<p translate>{{ 'app.admin.members_edit.you_intentionally_decide_to_extend_the_user_s_subscription_by_offering_him_free_days' }}</p>
<p translate>{{ 'app.admin.members_edit.credits_will_remain_unchanged' }}</p>
</div>
<div ng-hide="free">
<p translate>{{ 'app.admin.members_edit.you_intentionally_decide_to_extend_the_user_s_subscription_by_charging_him_again_for_his_current_subscription' }}</p>
<p translate>{{ 'app.admin.members_edit.credits_will_be_reset' }}</p>
<p translate>{{ 'app.admin.members_edit.payment_scheduled' }}</p>
</div>
</div>
<form role="form" name="subscriptionForm" novalidate>
<div class="form-group">
<label translate>{{ 'app.admin.members_edit.until_expiration_date' }}</label>
<input type="text"
class="form-control"
name="subscription[expired_at]"
ng-model="new_expired_at"
uib-datepicker-popup="{{datePicker.format}}"
datepicker-options="datePicker.options"
is-open="datePicker.opened"
ng-click="openDatePicker($event)"
min-date="datePicker.minDate"
placeholder=""
required/>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-warning" ng-click="ok()" ng-disabled="subscriptionForm.$invalid" translate>{{ 'app.shared.buttons.confirm' }}</button>
<button class="btn btn-primary" ng-click="cancel()" translate>{{ 'app.shared.buttons.cancel' }}</button>
</div>

View File

@ -216,6 +216,7 @@
after-success="afterLocalPaymentSuccess"
on-error="onLocalPaymentError"
cart="localPayment.cartItems"
update-cart="updateCart"
current-user="currentUser"
customer="user"
schedule="schedule.payment_schedule"/>

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
# A subscription extended for free, added to the shopping cart
class CartItem::FreeExtension < CartItem::BaseItem
def initialize(customer, subscription, new_expiration_date)
raise TypeError unless subscription.is_a? Subscription
@customer = customer
@new_expiration_date = new_expiration_date
@subscription = subscription
super
end
def start_at
raise InvalidSubscriptionError if @subscription.nil?
raise InvalidSubscriptionError if @new_expiration_date <= @subscription.expired_at
@subscription.expired_at
end
def price
elements = { OfferDay: 0 }
{ elements: elements, amount: 0 }
end
def name
I18n.t('cart_items.free_extension', DATE: I18n.l(@new_expiration_date))
end
def to_object
::OfferDay.new(
subscription_id: @subscription.id,
start_at: start_at,
end_at: @new_expiration_date
)
end
end

View File

@ -4,17 +4,19 @@
class CartItem::PaymentSchedule
attr_reader :requested
def initialize(plan, coupon, requested)
def initialize(plan, coupon, requested, customer, start_at = nil)
raise TypeError unless coupon.is_a? CartItem::Coupon
@plan = plan
@coupon = coupon
@requested = requested
@customer = customer
@start_at = start_at
end
def schedule(total, total_without_coupon)
schedule = if @requested && @plan&.monthly_payment
PaymentScheduleService.new.compute(@plan, total_without_coupon, coupon: @coupon.coupon)
PaymentScheduleService.new.compute(@plan, total_without_coupon, @customer, coupon: @coupon.coupon, start_at: @start_at)
else
nil
end

View File

@ -2,11 +2,14 @@
# A subscription added to the shopping cart
class CartItem::Subscription < CartItem::BaseItem
def initialize(plan, customer)
attr_reader :start_at
def initialize(plan, customer, start_at = nil)
raise TypeError unless plan.is_a? Plan
@plan = plan
@customer = customer
@start_at = start_at
super
end
@ -30,7 +33,8 @@ class CartItem::Subscription < CartItem::BaseItem
def to_object
::Subscription.new(
plan_id: @plan.id,
statistic_profile_id: StatisticProfile.find_by(user: @customer).id
statistic_profile_id: StatisticProfile.find_by(user: @customer).id,
start_at: @start_at
)
end
end

View File

@ -7,6 +7,8 @@ class OfferDay < ApplicationRecord
has_many :invoice_items, as: :object, dependent: :destroy
belongs_to :subscription
after_create :notify_subscription_extended
# buying invoice
def original_invoice
invoice_items.select(:invoice_id)
@ -15,4 +17,20 @@ class OfferDay < ApplicationRecord
.map { |id| Invoice.find_by(id: id, type: nil) }
.first
end
private
def notify_subscription_extended
meta_data = { free_days: true }
NotificationCenter.call type: :notify_member_subscription_extended,
receiver: subscription.user,
attached_object: subscription,
meta_data: meta_data
NotificationCenter.call type: :notify_admin_subscription_extended,
receiver: User.admins_and_managers,
attached_object: subscription,
meta_data: meta_data
end
end

View File

@ -22,7 +22,7 @@ class PaymentDocument < Footprintable
self.wallet_transaction_id = transaction_id
end
def post_save(arg); end
def post_save(*args); end
def render_resource; end
end

View File

@ -75,14 +75,10 @@ class PaymentSchedule < PaymentDocument
payment_schedule_items
end
def post_save(gateway_method_id)
def post_save(*args)
return unless payment_method == 'card'
PaymentGatewayService.new.create_subscription(self, gateway_method_id)
end
def post_save_extend(gateway_method_id)
PaymentGatewayService.new.extend_subscription(self, gateway_method_id)
PaymentGatewayService.new.create_subscription(self, *args)
end
def render_resource

View File

@ -122,6 +122,7 @@ class Setting < ApplicationRecord
renew_pack_threshold
pack_only_for_subscription] }
# WARNING: when adding a new key, you may also want to add it in app/policies/setting_policy.rb#public_whitelist
# and in config/locales/en.yml#settings
def value
last_value = history_values.order(HistoryValue.arel_table['created_at'].desc).limit(1).first

View File

@ -61,7 +61,7 @@ class ShoppingCart
payment = create_payment_document(price, objects, payment_id, payment_type)
WalletService.debit_user_wallet(payment, @customer)
payment.save
payment.post_save(payment_id)
payment.post_save(payment_id, payment_type)
end
success = !payment.nil? && objects.map(&:errors).flatten.map(&:empty?).all? && items.map(&:errors).map(&:empty?).all?
@ -89,10 +89,10 @@ class ShoppingCart
PaymentScheduleService.new.create(
objects,
price[:before_coupon],
@customer,
coupon: @coupon.coupon,
operator: @operator,
payment_method: @payment_method,
user: @customer,
payment_id: payment_id,
payment_type: payment_type
)

View File

@ -47,26 +47,6 @@ class Subscription < ApplicationRecord
expiration_date
end
def free_extend(expiration, operator_profile_id)
return false if expiration <= expired_at
od = offer_days.create(start_at: expired_at, end_at: expiration)
invoice = Invoice.new(
invoicing_profile: user.invoicing_profile,
statistic_profile: user.statistic_profile,
operator_profile_id: operator_profile_id,
total: 0
)
invoice.invoice_items.push InvoiceItem.new(amount: 0, description: plan.base_name, object: od)
invoice.save
if save
notify_subscription_extended(true)
return true
end
false
end
def user
statistic_profile.user
end
@ -116,9 +96,8 @@ class Subscription < ApplicationRecord
attached_object: self
end
def notify_subscription_extended(free_days)
meta_data = {}
meta_data[:free_days] = true if free_days
def notify_subscription_extended
meta_data = { free_days: false }
NotificationCenter.call type: :notify_member_subscription_extended,
receiver: user,
attached_object: self,
@ -131,7 +110,7 @@ class Subscription < ApplicationRecord
end
def set_expiration_date
start_at = DateTime.current.in_time_zone
start_at = self.start_at || DateTime.current.in_time_zone
self.expiration_date = start_at + plan.duration
end

View File

@ -3,6 +3,9 @@
# Check the access policies for API::LocalPaymentsController
class LocalPaymentPolicy < ApplicationPolicy
def confirm_payment?
user.admin? || (user.manager? && record.shopping_cart.customer.id != user.id) || record.price.zero?
# only admins and managers can offer free extensions of a subscription
has_free_days = record.shopping_cart.items.any? { |item| item.is_a? CartItem::FreeExtension }
user.admin? || (user.manager? && record.shopping_cart.customer.id != user.id) || (record.price.zero? && !has_free_days)
end
end

View File

@ -2,15 +2,11 @@
# Check the access policies for API::SubscriptionsController
class SubscriptionPolicy < ApplicationPolicy
def create?
Setting.get('plans_module') && (user.admin? || (user.manager? && record.user_id != user.id) || record.price.zero?)
end
def show?
user.admin? or record.user_id == user.id
end
def update?
user.admin? || (user.manager? && record.user.id != user.id)
def payment_details?
user.admin? || user.manager?
end
end

View File

@ -17,16 +17,18 @@ class CartService
items = []
cart_items[:items].each do |item|
if ['subscription', :subscription].include?(item.keys.first)
items.push(CartItem::Subscription.new(plan_info[:plan], @customer)) if plan_info[:new_subscription]
items.push(CartItem::Subscription.new(plan_info[:plan], @customer, item[:subscription][:start_at])) if plan_info[:new_subscription]
elsif ['reservation', :reservation].include?(item.keys.first)
items.push(reservable_from_hash(item[:reservation], plan_info))
elsif ['prepaid_pack', :prepaid_pack].include?(item.keys.first)
items.push(CartItem::PrepaidPack.new(PrepaidPack.find(item[:prepaid_pack][:id]), @customer))
elsif ['free_extension', :free_extension].include?(item.keys.first)
items.push(CartItem::FreeExtension.new(@customer, plan_info[:subscription], item[:free_extension][:end_at]))
end
end
coupon = CartItem::Coupon.new(@customer, @operator, cart_items[:coupon_code])
schedule = CartItem::PaymentSchedule.new(plan_info[:plan], coupon, cart_items[:payment_schedule])
schedule = CartItem::PaymentSchedule.new(plan_info[:plan], coupon, cart_items[:payment_schedule], @customer, plan_info[:subscription]&.start_at)
ShoppingCart.new(
@customer,
@ -40,19 +42,22 @@ class CartService
def from_payment_schedule(payment_schedule)
@customer = payment_schedule.user
plan = payment_schedule.payment_schedule_objects.find { |pso| pso.object_type == Subscription.name }&.subscription&.plan
subscription = payment_schedule.payment_schedule_objects.find { |pso| pso.object_type == Subscription.name }&.subscription
plan = subscription&.plan
coupon = CartItem::Coupon.new(@customer, @operator, payment_schedule.coupon&.code)
schedule = CartItem::PaymentSchedule.new(plan, coupon, true)
schedule = CartItem::PaymentSchedule.new(plan, coupon, true, @customer, subscription.start_at)
items = []
payment_schedule.payment_schedule_objects.each do |object|
if object.object_type == Subscription.name
items.push(CartItem::Subscription.new(object.subscription.plan, @customer))
items.push(CartItem::Subscription.new(object.subscription.plan, @customer, object.subscription.start_at))
elsif object.object_type == Reservation.name
items.push(reservable_from_payment_schedule_object(object, plan))
elsif object.object_type == PrepaidPack.name
items.push(CartItem::PrepaidPack.new(object.statistic_profile_prepaid_pack.prepaid_pack_id, @customer))
elsif object.object_type == OfferDay.name
items.push(CartItem::FreeExtension.new(@customer, object.offer_day.subscription, object.offer_day.end_date))
end
end
@ -70,18 +75,22 @@ class CartService
def plan(cart_items)
new_plan_being_bought = false
subscription = nil
plan = if cart_items[:items].any? { |item| ['subscription', :subscription].include?(item.keys.first) }
index = cart_items[:items].index { |item| ['subscription', :subscription].include?(item.keys.first) }
if cart_items[:items][index][:subscription][:plan_id]
new_plan_being_bought = true
Plan.find(cart_items[:items][index][:subscription][:plan_id])
plan = Plan.find(cart_items[:items][index][:subscription][:plan_id])
subscription = CartItem::Subscription.new(plan, @customer, cart_items[:items][index][:subscription][:start_at]).to_object
plan
end
elsif @customer.subscribed_plan
subscription = @customer.subscription unless @customer.subscription.expired_at < DateTime.current
@customer.subscribed_plan
else
nil
end
{ plan: plan, new_subscription: new_plan_being_bought }
{ plan: plan, subscription: subscription, new_subscription: new_plan_being_bought }
end
def customer(cart_items)

View File

@ -93,7 +93,7 @@ class InvoicesService
end
##
# Generate an array of {InvoiceItem} with the elements in provided reservation, price included.
# Generate an array of {InvoiceItem} with the provided elements, price included.
# @param invoice {Invoice} the parent invoice
# @param payment_details {Hash} as generated by ShoppingCart.total
# @param objects {Array<Reservation|Subscription|StatisticProfilePrepaidPack>}

View File

@ -19,12 +19,8 @@ class PaymentGatewayService
@gateway = service.new
end
def create_subscription(payment_schedule, gateway_object_id)
@gateway.create_subscription(payment_schedule, gateway_object_id)
end
def extend_subscription(payment_schedule, gateway_object_id)
@gateway.extend_subscription(payment_schedule, gateway_object_id)
def create_subscription(payment_schedule, *args)
@gateway.create_subscription(payment_schedule, *args)
end
def create_user(user_id)

View File

@ -6,9 +6,11 @@ class PaymentScheduleService
# Compute a payment schedule for a new subscription to the provided plan
# @param plan {Plan}
# @param total {Number} Total amount of the current shopping cart (which includes this plan) - without coupon
# @param customer {User} the customer
# @param coupon {Coupon} apply this coupon, if any
# @param start_at {DateTime} schedule the PaymentSchedule to start in the future
##
def compute(plan, total, coupon: nil)
def compute(plan, total, customer, coupon: nil, start_at: nil)
other_items = total - plan.amount
# base monthly price of the plan
price = plan.amount
@ -22,7 +24,7 @@ class PaymentScheduleService
end
items = []
(0..deadlines - 1).each do |i|
date = DateTime.current + i.months
date = (start_at || DateTime.current) + i.months
details = { recurring: per_month }
amount = if i.zero?
details[:adjustment] = adjustment.truncate
@ -46,14 +48,16 @@ class PaymentScheduleService
)
end
ps.total = items.map(&:amount).reduce(:+)
ps.invoicing_profile = customer.invoicing_profile
ps.statistic_profile = customer.statistic_profile
{ payment_schedule: ps, items: items }
end
def create(objects, total, coupon: nil, operator: nil, payment_method: nil, user: nil,
def create(objects, total, customer, coupon: nil, operator: nil, payment_method: nil,
payment_id: nil, payment_type: nil)
subscription = objects.find { |item| item.class == Subscription }
schedule = compute(subscription.plan, total, coupon: coupon)
schedule = compute(subscription.plan, total, customer, coupon: coupon, start_at: subscription.start_at)
ps = schedule[:payment_schedule]
items = schedule[:items]
@ -68,8 +72,6 @@ class PaymentScheduleService
ps.payment_gateway_objects.push(pgo)
end
ps.operator_profile = operator.invoicing_profile
ps.invoicing_profile = user.invoicing_profile
ps.statistic_profile = user.statistic_profile
ps.payment_schedule_items = items
ps
end

View File

@ -1,63 +0,0 @@
# frozen_string_literal: true
# Provides helper methods for Subscription actions
class Subscriptions::Subscribe
attr_accessor :user_id, :operator_profile_id
def initialize(operator_profile_id, user_id = nil)
@user_id = user_id
@operator_profile_id = operator_profile_id
end
def extend_subscription(subscription, new_expiration_date, free_days)
return subscription.free_extend(new_expiration_date, @operator_profile_id) if free_days
new_sub = Subscription.create(
plan_id: subscription.plan_id,
statistic_profile_id: subscription.statistic_profile_id
)
new_sub.expiration_date = new_expiration_date
if new_sub.save
schedule = subscription.original_payment_schedule
operator = InvoicingProfile.find(@operator_profile_id).user
cs = CartService.new(operator)
cart = cs.from_hash(customer_id: subscription.user.id,
items: [
{
subscription: {
plan_id: subscription.plan_id
}
}
],
payment_schedule: !schedule.nil?)
details = cart.total
payment = if schedule
operator = InvoicingProfile.find(operator_profile_id)&.user
PaymentScheduleService.new.create(
[new_sub],
details[:before_coupon],
operator: operator,
payment_method: schedule.payment_method,
user: new_sub.user,
payment_id: schedule.gateway_payment_mean&.id,
payment_type: schedule.gateway_payment_mean&.class
)
else
InvoicesService.create(
details,
operator_profile_id,
[new_sub],
new_sub.user
)
end
payment.save
payment.post_save_extend(schedule&.gateway_payment_mean&.id)
UsersCredits::Manager.new(user: new_sub.user).reset_credits
return new_sub
end
false
end
end

View File

@ -72,6 +72,7 @@ if member.subscription
json.interval member.subscription.plan.interval
json.interval_count member.subscription.plan.interval_count
json.amount member.subscription.plan.amount ? (member.subscription.plan.amount / 100.0) : 0
json.monthly_payment member.subscription.plan.monthly_payment
end
end
end

View File

@ -0,0 +1,4 @@
# frozen_string_literal: true
json.payment_schedule !@subscription.original_payment_schedule.nil?
json.card @subscription.original_invoice&.paid_by_card?

View File

@ -1 +1,3 @@
# frozen_string_literal: true
json.partial! 'api/subscriptions/subscription', subscription: @subscription

View File

@ -1 +0,0 @@
json.partial! 'api/subscriptions/subscription', subscription: @subscription

View File

@ -866,7 +866,7 @@ en:
expires_at: "Expires at:"
price_: "Price:"
offer_free_days: "Offer free days"
extend_subscription: "Extend subscription"
renew_subscription: "Renew the subscription"
user_has_no_current_subscription: "User has no current subscription."
subscribe_to_a_plan: "Subscribe to a plan"
trainings: "Trainings"
@ -888,13 +888,6 @@ en:
download_the_invoice: "Download the invoice"
download_the_refund_invoice: "Download the refund invoice"
no_invoices_for_now: "No invoices for now."
expiration_date: "Expiration date"
you_intentionally_decide_to_extend_the_user_s_subscription_by_offering_him_free_days: "You intentionally decide to extend the user's subscription by offering him free days."
credits_will_remain_unchanged: "The balance of free credits (training / machines / spaces) of the user will remain unchanged."
you_intentionally_decide_to_extend_the_user_s_subscription_by_charging_him_again_for_his_current_subscription: "You intentionally decide to extend the user's subscription by charging him again for his current subscription."
credits_will_be_reset: "The balance of free credits (training / machines / spaces) of the user will be reset, unused credits will be lost."
payment_scheduled: "If the previous subscription was charged through a payment schedule, this one will be charged the same way, the first deadline being charged right now, then each following month."
until_expiration_date: "Until (expiration date):"
you_successfully_changed_the_expiration_date_of_the_user_s_subscription: "You successfully changed the expiration date of the user's subscription"
a_problem_occurred_while_saving_the_date: "A problem occurred while saving the date."
new_subscription: "New subscription"
@ -906,6 +899,32 @@ en:
to_credit: 'Credit'
cannot_credit_own_wallet: "You cannot credit your own wallet. Please ask another manager or an administrator to credit your wallet."
cannot_extend_own_subscription: "You cannot extend your own subscription. Please ask another manager or an administrator to extend your subscription."
# extend a subscription for free
free_extend_modal:
extend_subscription: "Extend the subscription"
offer_free_days_infos: "You are about to extend the user's subscription by offering him free additional days."
credits_will_remain_unchanged: "The balance of free credits (training / machines / spaces) of the user will remain unchanged."
current_expiration: "Current subscription will expire at:"
DATE_TIME: "{DATE} {TIME}"
new_expiration_date: "New expiration date:"
number_of_free_days: "Number of free days:"
extend: "Extend"
extend_success: "The subscription was successfully extended for free"
# renew a subscription
renew_subscription_modal:
renew_subscription: "Renew the subscription"
renew_subscription_info: "You are about to renew the user's subscription by charging him again for his current subscription."
credits_will_be_reset: "The balance of free credits (training / machines / spaces) of the user will be reset, unused credits will be lost."
current_expiration: "Current subscription will expire at:"
new_start: "The new subscription will start at:"
new_expiration_date: "The new subscription will expire at:"
payment_schedule_card: "The previous subscription was charged by card through a payment schedule, this one will be charged the same way. The first deadline will be charged when the current subscription expires, then each following month."
payment_schedule_check: "The previous subscription was charged by check through a payment schedule, this one will be charged the same way. Before confirming please ensure you have all the checks to collect all the monthly payments."
one_payment_card: "The previous subscription was charged by card through a single payment, this one will be charged the same way. The payment will be charged right now."
one_payment_check: "The previous subscription was charged by check through a single payment, this one will be charged the same way. Before confirming please ensure you have collected the payment."
pay_in_one_go: "Pay in one go"
renew: "Renew"
renew_success: "The subscription was successfully renewed"
#add a new administrator to the platform
admins_new:
add_an_administrator: "Add an administrator"

View File

@ -417,7 +417,7 @@ en:
NUMBER_monthly_payment_of_AMOUNT: "{NUMBER} monthly {NUMBER, plural, =1{payment} other{payments}} of {AMOUNT}"
first_debit: "First debit on the day of the order."
debit: "Debit on the day of the order."
view_full_schedule: "View the complete payement schedule"
view_full_schedule: "View the complete payment schedule"
confirm_and_pay: "Confirm and pay"
you_have_settled_the_following_TYPE: "You have settled the following {TYPE, select, Machine{machine slots} Training{training} other{elements}}:"
you_have_settled_a_: "You have settled a"

View File

@ -416,6 +416,8 @@ en:
group:
#name of the user's group for administrators
admins: 'Administrators'
cart_items:
free_extension: "Free extension of a subscription, until %{DATE}"
settings:
locked_setting: "the setting is locked."
about_title: "\"About\" page title"
@ -529,3 +531,4 @@ en:
payzen_currency: "PayZen currency"
public_agenda_module: "Public agenda module"
renew_pack_threshold: "Threshold for packs renewal"
pack_only_for_subscription: "Restrict packs for subscribers"

View File

@ -98,7 +98,9 @@ Rails.application.routes.draw do
end
resources :groups, only: %i[index create update destroy]
resources :subscriptions, only: %i[show update]
resources :subscriptions, only: %i[show] do
get 'payment_details', action: 'payment_details', on: :member
end
resources :plan_categories
resources :plans do
get 'durations', on: :collection

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
# From this migration we save again the start_at field to subscriptions (was removed in 20140703100457_change_start_at_to_expired_at_from_subscription.rb).
# This is used to schedule subscriptions start at a future date
class AddStartAtAgainToSubscription < ActiveRecord::Migration[5.2]
def change
add_column :subscriptions, :start_at, :datetime
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2020_06_22_135401) do
ActiveRecord::Schema.define(version: 2021_10_14_135151) do
# These are extensions that must be enabled in order to support this database
enable_extension "fuzzystrmatch"
@ -19,8 +19,8 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
enable_extension "unaccent"
create_table "abuses", id: :serial, force: :cascade do |t|
t.integer "signaled_id"
t.string "signaled_type"
t.integer "signaled_id"
t.string "first_name"
t.string "last_name"
t.string "email"
@ -49,8 +49,8 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
t.string "locality"
t.string "country"
t.string "postal_code"
t.integer "placeable_id"
t.string "placeable_type"
t.integer "placeable_id"
t.datetime "created_at"
t.datetime "updated_at"
end
@ -64,8 +64,8 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
end
create_table "assets", id: :serial, force: :cascade do |t|
t.integer "viewable_id"
t.string "viewable_type"
t.integer "viewable_id"
t.string "attachment"
t.string "type"
t.datetime "created_at"
@ -133,8 +133,8 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
end
create_table "credits", id: :serial, force: :cascade do |t|
t.integer "creditable_id"
t.string "creditable_type"
t.integer "creditable_id"
t.integer "plan_id"
t.integer "hours"
t.datetime "created_at"
@ -207,6 +207,14 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
t.index ["user_id"], name: "index_exports_on_user_id"
end
create_table "footprint_debugs", force: :cascade do |t|
t.string "footprint"
t.string "data"
t.string "klass"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "friendly_id_slugs", id: :serial, force: :cascade do |t|
t.string "slug", null: false
t.integer "sluggable_id", null: false
@ -274,21 +282,20 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
create_table "invoice_items", id: :serial, force: :cascade do |t|
t.integer "invoice_id"
t.string "stp_invoice_item_id"
t.integer "amount"
t.datetime "created_at"
t.datetime "updated_at"
t.text "description"
t.integer "subscription_id"
t.integer "invoice_item_id"
t.string "footprint"
t.string "object_type"
t.bigint "object_id"
t.boolean "main"
t.index ["invoice_id"], name: "index_invoice_items_on_invoice_id"
t.index ["object_type", "object_id"], name: "index_invoice_items_on_object_type_and_object_id"
end
create_table "invoices", id: :serial, force: :cascade do |t|
t.integer "invoiced_id"
t.string "invoiced_type"
t.string "stp_invoice_id"
t.integer "total"
t.datetime "created_at"
t.datetime "updated_at"
@ -307,7 +314,6 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
t.integer "invoicing_profile_id"
t.integer "operator_profile_id"
t.integer "statistic_profile_id"
t.string "stp_payment_intent_id"
t.index ["coupon_id"], name: "index_invoices_on_coupon_id"
t.index ["invoice_id"], name: "index_invoices_on_invoice_id"
t.index ["invoicing_profile_id"], name: "index_invoices_on_invoicing_profile_id"
@ -350,15 +356,15 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
create_table "notifications", id: :serial, force: :cascade do |t|
t.integer "receiver_id"
t.integer "attached_object_id"
t.string "attached_object_type"
t.integer "attached_object_id"
t.integer "notification_type_id"
t.boolean "is_read", default: false
t.datetime "created_at"
t.datetime "updated_at"
t.string "receiver_type"
t.boolean "is_send", default: false
t.jsonb "meta_data", default: {}
t.jsonb "meta_data", default: "{}"
t.index ["notification_type_id"], name: "index_notifications_on_notification_type_id"
t.index ["receiver_id"], name: "index_notifications_on_receiver_id"
end
@ -421,6 +427,72 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
t.index ["invoicing_profile_id"], name: "index_organizations_on_invoicing_profile_id"
end
create_table "payment_gateway_objects", force: :cascade do |t|
t.string "gateway_object_id"
t.string "gateway_object_type"
t.string "item_type"
t.bigint "item_id"
t.bigint "payment_gateway_object_id"
t.index ["item_type", "item_id"], name: "index_payment_gateway_objects_on_item_type_and_item_id"
t.index ["payment_gateway_object_id"], name: "index_payment_gateway_objects_on_payment_gateway_object_id"
end
create_table "payment_schedule_items", force: :cascade do |t|
t.integer "amount"
t.datetime "due_date"
t.string "state", default: "new"
t.jsonb "details", default: "{}"
t.string "payment_method"
t.string "client_secret"
t.bigint "payment_schedule_id"
t.bigint "invoice_id"
t.string "footprint"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["invoice_id"], name: "index_payment_schedule_items_on_invoice_id"
t.index ["payment_schedule_id"], name: "index_payment_schedule_items_on_payment_schedule_id"
end
create_table "payment_schedule_objects", force: :cascade do |t|
t.string "object_type"
t.bigint "object_id"
t.bigint "payment_schedule_id"
t.boolean "main"
t.string "footprint"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["object_type", "object_id"], name: "index_payment_schedule_objects_on_object_type_and_object_id"
t.index ["payment_schedule_id"], name: "index_payment_schedule_objects_on_payment_schedule_id"
end
create_table "payment_schedules", force: :cascade do |t|
t.integer "total"
t.string "reference"
t.string "payment_method"
t.integer "wallet_amount"
t.bigint "wallet_transaction_id"
t.bigint "coupon_id"
t.string "footprint"
t.string "environment"
t.bigint "invoicing_profile_id"
t.bigint "statistic_profile_id"
t.bigint "operator_profile_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["coupon_id"], name: "index_payment_schedules_on_coupon_id"
t.index ["invoicing_profile_id"], name: "index_payment_schedules_on_invoicing_profile_id"
t.index ["operator_profile_id"], name: "index_payment_schedules_on_operator_profile_id"
t.index ["statistic_profile_id"], name: "index_payment_schedules_on_statistic_profile_id"
t.index ["wallet_transaction_id"], name: "index_payment_schedules_on_wallet_transaction_id"
end
create_table "plan_categories", force: :cascade do |t|
t.string "name"
t.integer "weight"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "plans", id: :serial, force: :cascade do |t|
t.string "name"
t.integer "amount"
@ -438,7 +510,10 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
t.integer "interval_count", default: 1
t.string "slug"
t.boolean "disabled"
t.boolean "monthly_payment"
t.bigint "plan_category_id"
t.index ["group_id"], name: "index_plans_on_group_id"
t.index ["plan_category_id"], name: "index_plans_on_plan_category_id"
end
create_table "plans_availabilities", id: :serial, force: :cascade do |t|
@ -448,6 +523,21 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
t.index ["plan_id"], name: "index_plans_availabilities_on_plan_id"
end
create_table "prepaid_packs", force: :cascade do |t|
t.string "priceable_type"
t.bigint "priceable_id"
t.bigint "group_id"
t.integer "amount"
t.integer "minutes"
t.string "validity_interval"
t.integer "validity_count"
t.boolean "disabled"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["group_id"], name: "index_prepaid_packs_on_group_id"
t.index ["priceable_type", "priceable_id"], name: "index_prepaid_packs_on_priceable_type_and_priceable_id"
end
create_table "price_categories", id: :serial, force: :cascade do |t|
t.string "name"
t.text "conditions"
@ -458,8 +548,8 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
create_table "prices", id: :serial, force: :cascade do |t|
t.integer "group_id"
t.integer "plan_id"
t.integer "priceable_id"
t.string "priceable_type"
t.integer "priceable_id"
t.integer "amount"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@ -531,6 +621,8 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
t.string "slug"
t.datetime "published_at"
t.integer "author_statistic_profile_id"
t.tsvector "search_vector"
t.index ["search_vector"], name: "projects_search_vector_idx", using: :gin
t.index ["slug"], name: "index_projects_on_slug", unique: true
end
@ -566,8 +658,8 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
t.text "message"
t.datetime "created_at"
t.datetime "updated_at"
t.integer "reservable_id"
t.string "reservable_type"
t.integer "reservable_id"
t.integer "nb_reserve_places"
t.integer "statistic_profile_id"
t.index ["reservable_type", "reservable_id"], name: "index_reservations_on_reservable_type_and_reservable_id"
@ -576,8 +668,8 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
create_table "roles", id: :serial, force: :cascade do |t|
t.string "name"
t.integer "resource_id"
t.string "resource_type"
t.integer "resource_id"
t.datetime "created_at"
t.datetime "updated_at"
t.index ["name", "resource_type", "resource_id"], name: "index_roles_on_name_and_resource_type_and_resource_id"
@ -671,6 +763,17 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
t.boolean "ca", default: true
end
create_table "statistic_profile_prepaid_packs", force: :cascade do |t|
t.bigint "prepaid_pack_id"
t.bigint "statistic_profile_id"
t.integer "minutes_used", default: 0
t.datetime "expires_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["prepaid_pack_id"], name: "index_statistic_profile_prepaid_packs_on_prepaid_pack_id"
t.index ["statistic_profile_id"], name: "index_statistic_profile_prepaid_packs_on_statistic_profile_id"
end
create_table "statistic_profile_trainings", id: :serial, force: :cascade do |t|
t.integer "statistic_profile_id"
t.integer "training_id"
@ -729,12 +832,12 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
create_table "subscriptions", id: :serial, force: :cascade do |t|
t.integer "plan_id"
t.string "stp_subscription_id"
t.datetime "created_at"
t.datetime "updated_at"
t.datetime "expiration_date"
t.datetime "canceled_at"
t.integer "statistic_profile_id"
t.datetime "start_at"
t.index ["plan_id"], name: "index_subscriptions_on_plan_id"
t.index ["statistic_profile_id"], name: "index_subscriptions_on_statistic_profile_id"
end
@ -827,7 +930,6 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
t.datetime "updated_at"
t.boolean "is_allow_contact", default: true
t.integer "group_id"
t.string "stp_customer_id"
t.string "username"
t.string "slug"
t.boolean "is_active", default: true
@ -868,15 +970,12 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
create_table "wallet_transactions", id: :serial, force: :cascade do |t|
t.integer "wallet_id"
t.integer "transactable_id"
t.string "transactable_type"
t.string "transaction_type"
t.integer "amount"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "invoicing_profile_id"
t.index ["invoicing_profile_id"], name: "index_wallet_transactions_on_invoicing_profile_id"
t.index ["transactable_type", "transactable_id"], name: "index_wallet_transactions_on_transactable"
t.index ["wallet_id"], name: "index_wallet_transactions_on_wallet_id"
end
@ -909,6 +1008,17 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
add_foreign_key "o_auth2_mappings", "o_auth2_providers"
add_foreign_key "open_api_calls_count_tracings", "open_api_clients"
add_foreign_key "organizations", "invoicing_profiles"
add_foreign_key "payment_gateway_objects", "payment_gateway_objects"
add_foreign_key "payment_schedule_items", "invoices"
add_foreign_key "payment_schedule_items", "payment_schedules"
add_foreign_key "payment_schedule_objects", "payment_schedules"
add_foreign_key "payment_schedules", "coupons"
add_foreign_key "payment_schedules", "invoicing_profiles"
add_foreign_key "payment_schedules", "invoicing_profiles", column: "operator_profile_id"
add_foreign_key "payment_schedules", "statistic_profiles"
add_foreign_key "payment_schedules", "wallet_transactions"
add_foreign_key "plans", "plan_categories"
add_foreign_key "prepaid_packs", "groups"
add_foreign_key "prices", "groups"
add_foreign_key "prices", "plans"
add_foreign_key "project_steps", "projects"
@ -929,6 +1039,8 @@ ActiveRecord::Schema.define(version: 2020_06_22_135401) do
add_foreign_key "spaces_availabilities", "availabilities"
add_foreign_key "spaces_availabilities", "spaces"
add_foreign_key "statistic_custom_aggregations", "statistic_types"
add_foreign_key "statistic_profile_prepaid_packs", "prepaid_packs"
add_foreign_key "statistic_profile_prepaid_packs", "statistic_profiles"
add_foreign_key "statistic_profile_trainings", "statistic_profiles"
add_foreign_key "statistic_profile_trainings", "trainings"
add_foreign_key "statistic_profiles", "groups"

View File

@ -33,6 +33,8 @@ For this guide, we will use [GitHub](https://developer.github.com/v3/oauth/) as
- **Client identifier**: Your Client ID, collected just before.
- **Client secret**: Your Client Secret, collected just before.
Please note that in some cases we'll encounter an issue unless the **common URL** must only contain the root domain (e.g. `http://github.com`), and the other parts of the URL must go to **Authorization endpoint** (e.g. `/login/oauth/authorize`) and **Token Acquisition Endpoint** (e.g. `/login/oauth/access_token`).
- Then you will need to define the matching of the fields between the Fab-manager and what the external SSO can provide.
Please note that the only mandatory field is `User.uid`.
To continue with our GitHub example, you will need to look at [this documentation page](https://developer.github.com/v3/users/#get-the-authenticated-user) to know witch field can be mapped and how, and [this one](https://developer.github.com/v3/) to know the root URL of the API.
@ -59,7 +61,7 @@ rails fablab:auth:switch_provider[GitHub]
- As the command just prompted you, you have to re-compile the assets
- In development, `rails tmp:clear` will do the job.
- In production with Docker, `rm -rf public/assets`, followed by `docker-compose run --rm fabmanager bundle exec rails assets:precompile`
- In production with Docker, `rm -rf public/packs`, followed by `docker-compose run --rm fabmanager bundle exec rails assets:precompile`
- Then restart the web-server or the container.
- Finally, to notify all existing users about the change (and send them their migration code/link), run:
```bash

View File

@ -9,20 +9,25 @@ class Integrity::Checksum
def code
dir = Dir.pwd
files = children_files("#{dir}/*")
.concat(children_files("#{dir}/app/**/*"))
.concat(children_files("#{dir}/bin/**/*"))
.concat(children_files("#{dir}/config/**/*"))
.concat(children_files("#{dir}/db/**/*"))
.concat(children_files("#{dir}/doc/**/*"))
.concat(children_files("#{dir}/docker/**/*"))
.concat(children_files("#{dir}/lib/**/*"))
.concat(children_files("#{dir}/node_modules/**/*"))
.concat(children_files("#{dir}/plugins/**/*"))
.concat(children_files("#{dir}/provision/**/*"))
.concat(children_files("#{dir}/scripts/**/*"))
.concat(children_files("#{dir}/test/**/*"))
.concat(children_files("#{dir}/vendor/**/*"))
files = if Rails.env.test?
# in test mode, we compute a "lite" checksum to speed-up test running time
children_files("#{dir}/*")
else
children_files("#{dir}/*")
.concat(children_files("#{dir}/app/**/*"))
.concat(children_files("#{dir}/bin/**/*"))
.concat(children_files("#{dir}/config/**/*"))
.concat(children_files("#{dir}/db/**/*"))
.concat(children_files("#{dir}/doc/**/*"))
.concat(children_files("#{dir}/docker/**/*"))
.concat(children_files("#{dir}/lib/**/*"))
.concat(children_files("#{dir}/node_modules/**/*"))
.concat(children_files("#{dir}/plugins/**/*"))
.concat(children_files("#{dir}/provision/**/*"))
.concat(children_files("#{dir}/scripts/**/*"))
.concat(children_files("#{dir}/test/**/*"))
.concat(children_files("#{dir}/vendor/**/*"))
end
content = files.map { |f| File.read(f) }.join

View File

@ -48,10 +48,6 @@ class PayZen::Service < Payment::Service
pgo_sub.save!
end
def extend_subscription(payment_schedule, payment_method_id)
create_subscription(payment_schedule, payment_method_id)
end
def process_payment_schedule_item(payment_schedule_item)
pz_order = payment_schedule_item.payment_schedule.gateway_order.retrieve
transaction = pz_order['answer']['transactions'].last

View File

@ -6,7 +6,7 @@ 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_subscription(_payment_schedule, *args); end
def create_user(_user_id); end

View File

@ -7,16 +7,17 @@ module Stripe; end
## create remote objects on stripe
class Stripe::Service < Payment::Service
# Create the provided PaymentSchedule on Stripe, using the Subscription API
# Build the subscription base on the given shopping cart and create it on the remote stripe API
def subscribe(payment_method_id, shopping_cart)
price_details = shopping_cart.total
payment_schedule = price_details[:schedule][:payment_schedule]
payment_schedule.payment_schedule_items = price_details[:schedule][:items]
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
subscription = shopping_cart.items.find { |item| item.class == CartItem::Subscription }.to_object
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, shopping_cart.customer, transaction: false)
handle_wallet_transaction(payment_schedule)
@ -27,48 +28,18 @@ class Stripe::Service < Payment::Service
# other items (not recurring)
items = subscription_invoice_items(payment_schedule, subscription, first_item, reservable_stp_id)
stripe_key = Setting.get('stripe_secret_key')
Stripe::Subscription.create({
customer: shopping_cart.customer.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 })
create_remote_subscription(shopping_cart, payment_schedule, items, price, payment_method_id, subscription)
end
def create_subscription(payment_schedule, payment_intent_id)
def create_subscription(payment_schedule, stp_object_id, stp_object_type)
stripe_key = Setting.get('stripe_secret_key')
pi = Stripe::PaymentIntent.retrieve({ id: payment_intent_id, expand: %w[invoice] }, { api_key: stripe_key })
stp_subscription = Stripe::Subscription.retrieve(pi.invoice.subscription, api_key: stripe_key)
pgo = PaymentGatewayObject.new(item: payment_schedule)
pgo.gateway_object = stp_subscription
pgo.save!
stp_subscription = Stripe::Item.new(stp_object_type, stp_object_id).retrieve
payment_method_id = stp_subscription.default_payment_method
payment_method_id = Stripe::Customer.retrieve(stp_subscription.customer, api_key: stripe_key).invoice_settings.default_payment_method
payment_method = Stripe::PaymentMethod.retrieve(payment_method_id, api_key: stripe_key)
pgo2 = PaymentGatewayObject.new(item: payment_schedule)
pgo2.gateway_object = payment_method
pgo2.save!
end
def extend_subscription(payment_schedule, payment_method_id)
# TODO, use Stripe::Subscription.update(sub_xxx, {cancel_at: new_date}, {api_key: stripe_key})
# FIXME, argument cart missing
stp_subscription = subscribe(payment_schedule, payment_method_id)
# not required?
handle_wallet_transaction(payment_schedule)
pgo = PaymentGatewayObject.new(item: payment_schedule)
pgo.gateway_object = stp_subscription
pgo.gateway_object = payment_method
pgo.save!
end
@ -181,6 +152,44 @@ class Stripe::Service < Payment::Service
private
# Create the provided PaymentSchedule on Stripe, using the Subscription API
def create_remote_subscription(shopping_cart, payment_schedule, items, price, payment_method_id, subscription)
stripe_key = Setting.get('stripe_secret_key')
if subscription.start_at.nil?
Stripe::Subscription.create({
customer: shopping_cart.customer.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 })
else
Stripe::SubscriptionSchedule.create({
customer: shopping_cart.customer.payment_gateway_object.gateway_object_id,
start_date: subscription.start_at.to_i,
end_behavior: 'cancel',
phases: [
{
items: [
{ price: price[:id] }
],
add_invoice_items: items,
coupon: payment_schedule.coupon&.code,
default_payment_method: payment_method_id,
end_date: (
payment_schedule.payment_schedule_items.max_by(&:due_date).due_date + 1.month
).to_i
}
]
}, { api_key: stripe_key })
end
end
def subscription_invoice_items(payment_schedule, subscription, first_item, reservable_stp_id)
second_item = payment_schedule.payment_schedule_items.sort_by(&:due_date)[1]

View File

@ -705,36 +705,10 @@ class Reservations::CreateTest < ActionDispatch::IntegrationTest
plan = Plan.find_by(group_id: @user_without_subscription.group.id, type: 'Plan', base_name: 'Abonnement mensualisable')
VCR.use_cassette('reservations_training_subscription_with_payment_schedule') do
post '/api/stripe/payment_schedule',
post '/api/stripe/setup_subscription',
params: {
payment_method_id: stripe_payment_method,
cart_items: {
items: [
{
subscription: {
plan_id: plan.id
}
}
],
payment_schedule: true,
payment_method: 'cart'
}
}.to_json, headers: default_headers
# Check response format & status
assert_equal 200, response.status, response.body
assert_equal Mime[:json], response.content_type
# Check the response
sub = json_response(response.body)
assert_not_nil sub[:id]
post '/api/stripe/confirm_payment_schedule',
params: {
subscription_id: sub[:id],
cart_items: {
payment_schedule: true,
payment_method: 'card',
items: [
{
reservation: {
@ -754,9 +728,19 @@ class Reservations::CreateTest < ActionDispatch::IntegrationTest
plan_id: plan.id
}
}
]
],
payment_schedule: true,
payment_method: 'cart'
}
}.to_json, headers: default_headers
# Check response format & status
assert_equal 201, response.status, response.body
assert_equal Mime[:json], response.content_type
# Check the response
sub = json_response(response.body)
assert_not_nil sub[:id]
end
# Check response format & status
@ -809,37 +793,10 @@ class Reservations::CreateTest < ActionDispatch::IntegrationTest
plan = Plan.find_by(group_id: user.group.id, type: 'Plan', base_name: 'Abonnement mensualisable')
VCR.use_cassette('reservations_machine_subscription_with_payment_schedule_coupon_wallet') do
post '/api/stripe/payment_schedule',
post '/api/stripe/setup_subscription',
params: {
payment_method_id: stripe_payment_method,
cart_items: {
items: [
{
subscription: {
plan_id: plan.id
}
}
],
payment_schedule: true,
payment_method: 'cart'
}
}.to_json, headers: default_headers
# Check response format & status
assert_equal 200, response.status, response.body
assert_equal Mime[:json], response.content_type
# Check the response
res = json_response(response.body)
assert_not_nil res[:id]
post '/api/stripe/confirm_payment_schedule',
params: {
subscription_id: res[:id],
cart_items: {
coupon_code: 'GIME3EUR',
payment_schedule: true,
payment_method: 'card',
items: [
{
reservation: {
@ -859,14 +816,22 @@ class Reservations::CreateTest < ActionDispatch::IntegrationTest
plan_id: plan.id
}
}
]
],
payment_schedule: true,
payment_method: 'card',
coupon_code: 'GIME3EUR'
}
}.to_json, headers: default_headers
# Check response format & status
assert_equal 201, response.status, response.body
assert_equal Mime[:json], response.content_type
# Check the response
res = json_response(response.body)
assert_not_nil res[:id]
end
# Check response format & status
assert_equal 201, response.status, response.body
assert_equal Mime[:json], response.content_type
assert_equal reservations_count + 1, Reservation.count, 'missing the reservation'
assert_equal invoice_count, Invoice.count, "an invoice was generated but it shouldn't"
assert_equal invoice_items_count, InvoiceItem.count, "some invoice items were generated but they shouldn't"
@ -896,7 +861,7 @@ class Reservations::CreateTest < ActionDispatch::IntegrationTest
assert_not_nil payment_schedule.reference
assert_equal 'card', payment_schedule.payment_method
assert_equal 2, payment_schedule.payment_gateway_objects.count
#assert_not_nil payment_schedule.gateway_payment_mean
assert_not_nil payment_schedule.gateway_payment_mean
assert_not_nil payment_schedule.wallet_transaction
assert_equal payment_schedule.ordered_items.first.amount, payment_schedule.wallet_amount
assert_equal Coupon.find_by(code: 'GIME3EUR').id, payment_schedule.coupon_id

View File

@ -77,7 +77,7 @@ class Subscriptions::CreateAsAdminTest < ActionDispatch::IntegrationTest
payment_schedule_items_count = PaymentScheduleItem.count
VCR.use_cassette('subscriptions_admin_create_with_payment_schedule') do
post '/api/stripe/payment_schedule',
post '/api/stripe/setup_subscription',
params: {
payment_method_id: stripe_payment_method,
cart_items: {
@ -95,34 +95,15 @@ class Subscriptions::CreateAsAdminTest < ActionDispatch::IntegrationTest
}.to_json, headers: default_headers
# Check response format & status
assert_equal 200, response.status, response.body
assert_equal 201, response.status, response.body
assert_equal Mime[:json], response.content_type
# Check the response
res = json_response(response.body)
assert_not_nil res[:id]
post '/api/stripe/confirm_payment_schedule',
params: {
subscription_id: res[:id],
cart_items: {
customer_id: user.id,
payment_schedule: true,
payment_method: 'card',
items: [
{
subscription: {
plan_id: plan.id
}
}
]
}
}.to_json, headers: default_headers
end
# Check generalities
assert_equal 201, response.status, response.body
assert_equal Mime[:json], response.content_type
assert_equal invoice_count, Invoice.count, "an invoice was generated but it shouldn't"
assert_equal payment_schedule_count + 1, PaymentSchedule.count, 'missing the payment schedule'
assert_equal payment_schedule_items_count + 12, PaymentScheduleItem.count, 'missing some payment schedule items'

View File

@ -195,7 +195,7 @@ class Subscriptions::CreateAsUserTest < ActionDispatch::IntegrationTest
payment_schedule_items_count = PaymentScheduleItem.count
VCR.use_cassette('subscriptions_user_create_with_payment_schedule') do
post '/api/stripe/payment_schedule',
post '/api/stripe/setup_subscription',
params: {
payment_method_id: stripe_payment_method,
cart_items: {
@ -211,18 +211,68 @@ class Subscriptions::CreateAsUserTest < ActionDispatch::IntegrationTest
}
}.to_json, headers: default_headers
# Check response format & status
assert_equal 201, response.status, response.body
assert_equal Mime[:json], response.content_type
# Check the response
sub = json_response(response.body)
assert_not_nil sub[:id]
end
# Check generalities
assert_equal payment_schedule_count + 1, PaymentSchedule.count, 'missing the payment schedule'
assert_equal payment_schedule_items_count + 12, PaymentScheduleItem.count, 'missing some payment schedule items'
# Check the correct plan was subscribed
result = json_response(response.body)
assert_equal PaymentSchedule.last.id, result[:id], 'payment schedule id does not match'
subscription = PaymentSchedule.find(result[:id]).payment_schedule_objects.first.object
assert_equal plan.id, subscription.plan_id, 'subscribed plan does not match'
# Check that the user has the correct subscription
assert_not_nil @user.subscription, "user's subscription was not found"
assert_not_nil @user.subscription.plan, "user's subscribed plan was not found"
assert_equal plan.id, @user.subscription.plan_id, "user's plan does not match"
end
test 'user takes a subscription but does not confirm 3DS' do
plan = Plan.find_by(group_id: @user.group.id, type: 'Plan', base_name: 'Abonnement mensualisable')
payment_schedule_count = PaymentSchedule.count
payment_schedule_items_count = PaymentScheduleItem.count
VCR.use_cassette('subscriptions_user_create_without_3ds_confirmation') do
post '/api/stripe/setup_subscription',
params: {
payment_method_id: stripe_payment_method(error: :require_3ds),
cart_items: {
items: [
{
subscription: {
plan_id: plan.id
}
}
],
payment_schedule: true,
payment_method: 'cart'
}
}.to_json, headers: default_headers
# Check response format & status
assert_equal 200, response.status, response.body
assert_equal Mime[:json], response.content_type
# Check the response
sub = json_response(response.body)
assert_not_nil sub[:id]
res = json_response(response.body)
assert res[:requires_action]
assert_not_nil res[:payment_intent_client_secret]
assert_not_nil res[:subscription_id]
assert_equal 'subscription', res[:type]
# create the subscription
post '/api/stripe/confirm_payment_schedule',
# try to confirm the subscription
post '/api/stripe/confirm_subscription',
params: {
subscription_id: sub[:id],
subscription_id: res[:subscription_id],
cart_items: {
payment_schedule: true,
payment_method: 'card',
@ -238,20 +288,19 @@ class Subscriptions::CreateAsUserTest < ActionDispatch::IntegrationTest
end
# Check generalities
assert_equal 201, response.status, response.body
assert_equal 200, response.status, response.body
assert_equal Mime[:json], response.content_type
assert_equal payment_schedule_count + 1, PaymentSchedule.count, 'missing the payment schedule'
assert_equal payment_schedule_items_count + 12, PaymentScheduleItem.count, 'missing some payment schedule items'
# Check the correct plan was subscribed
result = json_response(response.body)
assert_equal PaymentSchedule.last.id, result[:id], 'payment schedule id does not match'
subscription = PaymentSchedule.find(result[:id]).payment_schedule_objects.first.object
assert_equal plan.id, subscription.plan_id, 'subscribed plan does not match'
res = json_response(response.body)
assert res[:requires_action]
assert_not_nil res[:payment_intent_client_secret]
assert_not_nil res[:subscription_id]
assert_equal 'subscription', res[:type]
# Check that the user has the correct subscription
assert_not_nil @user.subscription, "user's subscription was not found"
assert_not_nil @user.subscription.plan, "user's subscribed plan was not found"
assert_equal plan.id, @user.subscription.plan_id, "user's plan does not match"
assert_equal payment_schedule_count, PaymentSchedule.count, 'the payment schedule was created anyway'
assert_equal payment_schedule_items_count, PaymentScheduleItem.count, 'some payment schedule items were created anyway'
# Check that the user has no subscription
assert_nil @user.subscription, "user's subscription was not found"
end
end

View File

@ -2,6 +2,9 @@
require 'test_helper'
module Subscriptions; end
class Subscriptions::RenewAsAdminTest < ActionDispatch::IntegrationTest
setup do
@admin = User.find_by(username: 'admin')
@ -81,29 +84,38 @@ class Subscriptions::RenewAsAdminTest < ActionDispatch::IntegrationTest
user = User.find_by(username: 'pdurand')
subscription = user.subscription.clone
new_date = (1.month.from_now - 4.days).utc
offer_days_count = OfferDay.count
VCR.use_cassette('subscriptions_admin_offer_free_days') do
put "/api/subscriptions/#{subscription.id}",
params: {
subscription: {
expired_at: new_date.strftime('%Y-%m-%d %H:%M:%S.%9N Z'),
free: true
}
}.to_json, headers: default_headers
post '/api/local_payment/confirm_payment',
params: {
customer_id: user.id,
items: [
{
free_extension: {
end_at: new_date.strftime('%Y-%m-%d %H:%M:%S.%9N Z')
}
}
]
}.to_json, headers: default_headers
end
# Check response format & status
assert_equal 200, response.status, response.body
assert_equal 201, response.status, response.body
assert_equal Mime[:json], response.content_type
# Check that the subscribed plan was not altered
res_subscription = json_response(response.body)
assert_equal subscription.id, res_subscription[:id], 'subscription id has changed'
assert_equal subscription.plan_id, res_subscription[:plan_id], 'subscribed plan does not match'
assert_dates_equal new_date, res_subscription[:expired_at], 'subscription end date was not updated'
res = json_response(response.body)
assert_equal 'OfferDay', res[:main_object][:type]
assert_equal 0, res[:items][0][:amount]
assert_equal subscription.id, user.subscription.id, 'subscription id has changed'
assert_equal subscription.plan_id, user.subscription.plan_id, 'subscribed plan does not match'
assert_dates_equal new_date, user.subscription.expired_at, 'subscription end date was not updated'
# Check the subscription was correctly saved
assert_equal 1, user.subscriptions.count
assert_equal offer_days_count + 1, OfferDay.count
# Check notification was sent to the user
notification = Notification.find_by(
@ -120,31 +132,39 @@ class Subscriptions::RenewAsAdminTest < ActionDispatch::IntegrationTest
test 'admin successfully extends a subscription' do
user = User.find_by(username: 'pdurand')
subscription = user.subscription.clone
new_date = (1.month.from_now - 4.days).utc
new_date = subscription.expired_at + subscription.plan.interval_count.send(subscription.plan.interval)
VCR.use_cassette('subscriptions_admin_extends_subscription') do
put "/api/subscriptions/#{subscription.id}",
params: {
subscription: {
expired_at: new_date.strftime('%Y-%m-%d %H:%M:%S.%9N Z')
}
}.to_json, headers: default_headers
post '/api/local_payment/confirm_payment',
params: {
customer_id: user.id,
payment_method: 'check',
payment_schedule: false,
items: [
{
subscription: {
start_at: subscription.expired_at.strftime('%Y-%m-%d %H:%M:%S.%9N Z'),
plan_id: subscription.plan_id
}
}
]
}.to_json, headers: default_headers
end
# Check response format & status
assert_equal 201, response.status, response.body
assert_equal Mime[:json], response.content_type
# Check that the subscribed plan is still the same
res_subscription = json_response(response.body)
assert_equal subscription.plan_id, res_subscription[:plan_id], 'subscribed plan does not match'
assert_equal 'Subscription', res_subscription[:main_object][:type]
assert_equal subscription.plan.amount / 100.0, res_subscription[:items][0][:amount]
# Check the subscription was correctly saved
assert_equal 2, user.subscriptions.count
# Check that the subscription is new
assert_not_equal subscription.id, res_subscription[:id], 'subscription id has not changed'
assert_dates_equal new_date, res_subscription[:expired_at], 'subscription end date does not match'
assert_not_equal subscription.id, user.subscription.id, 'subscription id has not changed'
assert_dates_equal new_date, user.subscription.expired_at, 'subscription end date does not match'
# Check notification was sent to the user
notification = Notification.find_by(

View File

@ -57,6 +57,8 @@ class ActiveSupport::TestCase
exp_year = 1964
when /invalid_cvc/
cvc = '99'
when /require_3ds/
number = '4000002760003184'
end
Stripe::PaymentMethod.create(

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long