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:
commit
1237870450
@ -18,6 +18,7 @@ Metrics/BlockLength:
|
||||
- 'app/pdfs/pdf/*.rb'
|
||||
- 'test/**/*.rb'
|
||||
Metrics/ParameterLists:
|
||||
Max: 6
|
||||
CountKeywordArgs: false
|
||||
Style/BracesAroundHashParameters:
|
||||
EnforcedStyle: context_dependent
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
20
app/frontend/src/javascript/api/subscription.ts
Normal file
20
app/frontend/src/javascript/api/subscription.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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 (
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>}
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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']));
|
||||
|
@ -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")
|
||||
|
@ -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>}
|
||||
|
@ -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>
|
||||
|
@ -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']));
|
@ -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']));
|
@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -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 */
|
||||
|
||||
/**
|
||||
|
@ -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 €")
|
||||
*/
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -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";
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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' }}
|
||||
|
@ -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>
|
@ -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"/>
|
||||
|
38
app/models/cart_item/free_extension.rb
Normal file
38
app/models/cart_item/free_extension.rb
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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>}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
@ -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
|
||||
|
@ -0,0 +1,4 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.payment_schedule !@subscription.original_payment_schedule.nil?
|
||||
json.card @subscription.original_invoice&.paid_by_card?
|
@ -1 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.partial! 'api/subscriptions/subscription', subscription: @subscription
|
||||
|
@ -1 +0,0 @@
|
||||
json.partial! 'api/subscriptions/subscription', subscription: @subscription
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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
|
154
db/schema.rb
154
db/schema.rb
@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 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"
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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]
|
||||
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user