1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-29 18:52:22 +01:00

handle stripe requires_action

This commit is contained in:
Sylvain 2021-02-08 15:28:47 +01:00
parent 38ac55c35f
commit 0e7226c85c
32 changed files with 268 additions and 86 deletions

View File

@ -4,7 +4,7 @@
class API::PaymentSchedulesController < API::ApiController
before_action :authenticate_user!
before_action :set_payment_schedule, only: %i[download]
before_action :set_payment_schedule_item, only: %i[cash_check]
before_action :set_payment_schedule_item, only: %i[cash_check refresh_item]
def list
authorize PaymentSchedule
@ -35,6 +35,13 @@ class API::PaymentSchedulesController < API::ApiController
render json: attrs, status: :ok
end
def refresh_item
authorize @payment_schedule_item.payment_schedule
PaymentScheduleItemWorker.new.perform(params[:id])
render json: { state: 'refreshed' }, status: :ok
end
private
def set_payment_schedule

View File

@ -18,6 +18,11 @@ export default class PaymentScheduleAPI {
return res?.data;
}
async refreshItem(paymentScheduleItemId: number): Promise<void> {
const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/refresh_item`);
return res?.data;
}
static list (query: PaymentScheduleIndexRequest): IWrapPromise<Array<PaymentSchedule>> {
const api = new PaymentScheduleAPI();
return wrapPromise(api.list(query));

View File

@ -8,10 +8,11 @@ interface FabButtonProps {
onClick?: (event: SyntheticEvent) => void,
icon?: ReactNode,
className?: string,
disabled?: boolean,
}
export const FabButton: React.FC<FabButtonProps> = ({ onClick, icon, className, children }) => {
export const FabButton: React.FC<FabButtonProps> = ({ onClick, icon, className, disabled, children }) => {
/**
* Check if the current component was provided an icon to display
*/
@ -29,7 +30,7 @@ export const FabButton: React.FC<FabButtonProps> = ({ onClick, icon, className,
}
return (
<button onClick={handleClick} className={`fab-button ${className ? className : ''}`}>
<button onClick={handleClick} disabled={disabled} className={`fab-button ${className ? className : ''}`}>
{hasIcon() && <span className="fab-button--icon">{icon}</span>}
{children}
</button>

View File

@ -27,12 +27,13 @@ interface FabModalProps {
className?: string,
width?: ModalSize,
customFooter?: ReactNode,
onConfirm?: (event: SyntheticEvent) => void
onConfirm?: (event: SyntheticEvent) => void,
preventConfirm?: boolean
}
const blackLogoFile = CustomAssetAPI.get(CustomAssetName.LogoBlackFile);
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customFooter, onConfirm }) => {
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customFooter, onConfirm, preventConfirm }) => {
const { t } = useTranslation('shared');
const blackLogo = blackLogoFile.read();
@ -58,7 +59,7 @@ export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal,
}
return (
<Modal isOpen={isOpen}onConfirm
<Modal isOpen={isOpen}
className={`fab-modal fab-modal-${width} ${className}`}
overlayClassName="fab-modal-overlay"
onRequestClose={toggleModal}>
@ -76,7 +77,7 @@ export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal,
<div className="fab-modal-footer">
<Loader>
{hasCloseButton() &&<FabButton className="modal-btn--close" onClick={toggleModal}>{t('app.shared.buttons.close')}</FabButton>}
{hasConfirmButton() && <FabButton className="modal-btn--confirm" onClick={onConfirm}>{confirmButton}</FabButton>}
{hasConfirmButton() && <FabButton className="modal-btn--confirm" disabled={preventConfirm} onClick={onConfirm}>{confirmButton}</FabButton>}
{hasCustomFooter() && customFooter}
</Loader>
</div>

View File

@ -12,6 +12,8 @@ import { PaymentSchedule, PaymentScheduleItem, PaymentScheduleItemState } from '
import { FabButton } from './fab-button';
import { FabModal } from './fab-modal';
import PaymentScheduleAPI from '../api/payment-schedule';
import { StripeElements } from './stripe-elements';
import { StripeConfirm } from './stripe-confirm';
declare var Fablab: IFablab;
@ -26,6 +28,8 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
const [showExpanded, setShowExpanded] = useState<Map<number, boolean>>(new Map());
const [showConfirmCashing, setShowConfirmCashing] = useState<boolean>(false);
const [showResolveAction, setShowResolveAction] = useState<boolean>(false);
const [isConfirmActionDisabled, setConfirmActionDisabled] = useState<boolean>(true);
const [tempDeadline, setTempDeadline] = useState<PaymentScheduleItem>(null);
/**
@ -149,6 +153,9 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
}
}
/**
* Callback triggered when the user's clicks on the "cash check" button: show a confirmation modal
*/
const handleConfirmCheckPayment = (item: PaymentScheduleItem): ReactEventHandler => {
return (): void => {
setTempDeadline(item);
@ -156,6 +163,9 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
}
}
/**
* After the user has confirmed that he wants to cash the check, update the API, refresh the list and close the modal.
*/
const onCheckCashingConfirmed = (): void => {
const api = new PaymentScheduleAPI();
api.cashCheck(tempDeadline.id).then((res) => {
@ -174,37 +184,44 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
}
/**
* Dynamically build the content of the modal depending on the currently selected deadline
* Show/hide the modal dialog that trigger the card "action".
*/
const cashingModalContent = (): ReactNode => {
if (tempDeadline) {
return (
<span>{t('app.admin.invoices.schedules_table.confirm_check_cashing_body', {
AMOUNT: formatPrice(tempDeadline.amount),
DATE: formatDate(tempDeadline.due_date)
})}</span>
);
}
return <span />;
const toggleResolveActionModal = (): void => {
setShowResolveAction(!showResolveAction);
}
/**
* Callback triggered when the user's clicks on the "resolve" button: show a modal that will trigger the action
*/
const handleSolveAction = (item: PaymentScheduleItem): ReactEventHandler => {
return (): void => {
/*
TODO
- create component wrapped with <StripeElements>
- stripe.confirmCardSetup(item.client_secret).then(function(result) {
if (result.error) {
// Display error.message in your UI.
} else {
// The setup has succeeded. Display a success message.
}
});
*/
setTempDeadline(item);
toggleResolveActionModal();
}
}
/**
* After the action was done (successfully or not), ask the API to refresh the item status, then refresh the list and close the modal
*/
const afterAction = (): void => {
toggleConfirmActionButton();
const api = new PaymentScheduleAPI();
api.refreshItem(tempDeadline.id).then(() => {
refreshList();
toggleResolveActionModal();
});
}
/**
* Enable/disable the confirm button of the "action" modal
*/
const toggleConfirmActionButton = (): void => {
setConfirmActionDisabled(!isConfirmActionDisabled);
}
/**
* Callback triggered when the user's clicks on the "update card" button: show a modal to input a new card
*/
const handleUpdateCard = (item: PaymentScheduleItem): ReactEventHandler => {
return (): void => {
/*
@ -281,8 +298,23 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
onConfirm={onCheckCashingConfirmed}
closeButton={true}
confirmButton={t('app.admin.invoices.schedules_table.confirm_button')}>
{cashingModalContent()}
{tempDeadline && <span>
{t('app.admin.invoices.schedules_table.confirm_check_cashing_body', {
AMOUNT: formatPrice(tempDeadline.amount),
DATE: formatDate(tempDeadline.due_date)
})}
</span>}
</FabModal>
<StripeElements>
<FabModal title={t('app.admin.invoices.schedules_table.resolve_action')}
isOpen={showResolveAction}
toggleModal={toggleResolveActionModal}
onConfirm={afterAction}
confirmButton={t('app.admin.invoices.schedules_table.ok_button')}
preventConfirm={isConfirmActionDisabled}>
{tempDeadline && <StripeConfirm clientSecret={tempDeadline.client_secret} onResponse={toggleConfirmActionButton} />}
</FabModal>
</StripeElements>
</div>
</div>
);

View File

@ -0,0 +1,34 @@
import React, { useEffect, useState } from 'react';
import { useStripe } from '@stripe/react-stripe-js';
import { useTranslation } from 'react-i18next';
interface StripeConfirmProps {
clientSecret: string,
onResponse: () => void,
}
export const StripeConfirm: React.FC<StripeConfirmProps> = ({ clientSecret, onResponse }) => {
const stripe = useStripe();
const { t } = useTranslation('shared');
const [message, setMessage] = useState<string>(t('app.shared.stripe_confirm.pending'));
const [type, setType] = useState<string>('info');
useEffect(() => {
stripe.confirmCardPayment(clientSecret).then(function(result) {
onResponse();
if (result.error) {
// Display error.message in your UI.
setType('error');
setMessage(result.error.message);
} else {
// The setup has succeeded. Display a success message.
setType('success');
setMessage(t('app.shared.stripe_confirm.success'));
}
});
}, [])
return <div className="stripe-confirm">
<div className={`message--${type}`}><span className="message-text">{message}</span></div>
</div>;
}

View File

@ -21,8 +21,10 @@ export interface PaymentScheduleItem {
client_secret?: string,
details: {
recurring: number,
adjustment: number,
other_items: number
adjustment?: number,
other_items?: number,
without_coupon?: number,
subscription_id: number
}
}

View File

@ -30,5 +30,6 @@
@import "modules/document-filters";
@import "modules/payment-schedules-table";
@import "modules/payment-schedules-list";
@import "modules/stripe-confirm";
@import "app.responsive";

View File

@ -33,6 +33,15 @@
box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
}
&[disabled] {
color: #3a3a3a;
}
&[disabled]:hover {
color: #3a3a3a;
}
&--icon {
margin-right: 0.5em;
}

View File

@ -0,0 +1,41 @@
@keyframes spin { 100% { transform:rotate(360deg); } }
.stripe-confirm {
.message {
&--success:before {
font-family: 'Font Awesome 5 Free';
font-weight: 900;
content: "\f00c";
color: #3c763d;
margin-right: 0.5em;
}
&--error:before {
font-family: 'Font Awesome 5 Free';
font-weight: 900;
content: "\f00d";
color: #840b0f;
margin-right: 0.5em;
}
&--info:before {
font-family: 'Font Awesome 5 Free';
font-weight: 900;
content: "\f1ce";
color: #a0a0a0;
margin-right: 2em;
animation:spin 2s linear infinite;
position: absolute;
}
&--info {
.message-text {
margin-left: 1.5em;
}
}
}
.message-text {
margin-left: 0.5em;
}
}

View File

@ -40,7 +40,7 @@ class Coupon < ApplicationRecord
end
def usages
invoices.count + payment_schedule.count
invoices.count
end
##

View File

@ -19,6 +19,6 @@ class PaymentScheduleItem < Footprintable
end
def self.columns_out_of_footprint
%w[invoice_id stp_invoice_id state payment_method]
%w[invoice_id stp_invoice_id state payment_method client_secret]
end
end

View File

@ -10,6 +10,10 @@ class PaymentSchedulePolicy < ApplicationPolicy
user.admin? || user.manager?
end
def refresh_item?
user.admin? || user.manager? || (record.invoicing_profile.user_id == user.id)
end
def download?
user.admin? || user.manager? || (record.invoicing_profile.user_id == user.id)
end

View File

@ -142,7 +142,7 @@ class PaymentScheduleService
##
# The first PaymentScheduleItem contains references to the reservation price (if any) and to the adjustement price
# for the subscription (if any)
# for the subscription (if any) and the wallet transaction (if any)
##
def complete_first_invoice(payment_schedule_item, invoice)
# sub-prices for the subscription and the reservation
@ -157,6 +157,10 @@ class PaymentScheduleService
reservation = payment_schedule_item.payment_schedule.scheduled
end
# the wallet transaction
invoice[:wallet_amount] = payment_schedule_item.payment_schedule.wallet_amount
invoice[:wallet_transaction_id] = payment_schedule_item.payment_schedule.wallet_transaction_id
# build the invoice items
generate_invoice_items(invoice, details, subscription: subscription, reservation: reservation)
end

View File

@ -4,6 +4,7 @@ $primary-dark: <%= Stylesheet.primary_dark %> !default;
$secondary: <%= Stylesheet.secondary %> !default;
$secondary-light: <%= Stylesheet.secondary_light %> !default;
$secondary-lighter: lighten(<%= Stylesheet.secondary_light %>, 20%) !default;
$secondary-dark: <%= Stylesheet.secondary_dark %> !default;
$primary-text-color: <%= Stylesheet.primary_text_color %> !default;
@ -320,10 +321,22 @@ section#cookies-modal div.cookies-consent .cookies-actions button.accept {
}
&:hover {
background-color: $secondary-dark !important;
border-color: $secondary-dark !important;
background-color: $secondary-dark;
border-color: $secondary-dark;
color: $secondary-text-color;
}
&[disabled] {
background-color: $secondary-light;
color: $secondary-lighter;
border-color: $secondary-lighter;
}
&[disabled]:hover {
background-color: $secondary-light;
color: $secondary-lighter;
border-color: $secondary-lighter;
}
}
}
}

View File

@ -3,8 +3,8 @@
<p>
<%= t('.body.remember',
REFERENCE: @attached_object.payment_schedule.reference,
AMOUNT: number_to_currency(@attached_object.amount),
DATE: I18n.l @attached_object.due_date, format: :long) %>
AMOUNT: number_to_currency(@attached_object.amount / 100.00),
DATE: I18n.l(@attached_object.due_date, format: :long)) %>
<%= t('.body.date') %>
</p>
<p><%= t('.body.confirm') %></p>

View File

@ -3,8 +3,8 @@
<p>
<%= t('.body.remember',
REFERENCE: @attached_object.payment_schedule.reference,
AMOUNT: number_to_currency(@attached_object.amount),
DATE: I18n.l @attached_object.due_date, format: :long) %>
AMOUNT: number_to_currency(@attached_object.amount / 100.00),
DATE: I18n.l(@attached_object.due_date, format: :long)) %>
<%= t('.body.error') %>
</p>
<p><%= t('.body.action') %></p>

View File

@ -3,7 +3,7 @@
<p><%= t('.body.member_cancelled', NAME: @attached_object.reservation.user&.profile&.full_name || t('api.notifications.deleted_user')) %></p>
<p><%= t('.body.item_details',
START: I18n.l(@attached_object.start_at, format: :long),
END:(I18n.l @attached_object.end_at, format: :hour_minute),
END: I18n.l(@attached_object.end_at, format: :hour_minute),
RESERVABLE: @attached_object.reservation.reservable.name) %>
</p>
<p><%= t('.body.generate_refund') %></p>

View File

@ -1,5 +1,5 @@
<%= render 'notifications_mailer/shared/hello', recipient: @recipient %>
<p><%= t('.body.slot_modified', NAME: @attached_object.reservation.user&.profile&.full_name || t('api.notifications.deleted_user')) %></p>
<p><%= t('.body.new_date') %> <%= "#{I18n.l @attached_object.start_at, format: :long} - #{I18n.l @attached_object.end_at, format: :hour_minute}" %></p>
<p><small><%= t('.body.old_date') %> <%= "#{I18n.l @attached_object.ex_start_at, format: :long} - #{I18n.l @attached_object.ex_end_at, format: :hour_minute}" %></small></p>
<p><%= t('.body.new_date') %> <%= "#{I18n.l(@attached_object.start_at, format: :long)} - #{I18n.l(@attached_object.end_at, format: :hour_minute)}" %></p>
<p><small><%= t('.body.old_date') %> <%= "#{I18n.l(@attached_object.ex_start_at, format: :long)} - #{I18n.l(@attached_object.ex_end_at, format: :hour_minute)}" %></small></p>

View File

@ -3,8 +3,8 @@
<p>
<%= t('.body.remember',
REFERENCE: @attached_object.payment_schedule.reference,
AMOUNT: number_to_currency(@attached_object.amount),
DATE: I18n.l @attached_object.due_date, format: :long) %>
AMOUNT: number_to_currency(@attached_object.amount / 100.00),
DATE: I18n.l(@attached_object.due_date, format: :long)) %>
<%= t('.body.error') %>
</p>
<p><%= t('.body.action') %></p>
<p><%= t('.body.action', DASHBOARD: link_to(t('.body.your_dashboard'), "#{root_url}#!/dashboard/invoices")) %></p>

View File

@ -1,4 +1,4 @@
<%= render 'notifications_mailer/shared/hello', recipient: @recipient %>
<p><%= t('.body.reservation_canceled', RESERVABLE: @attached_object.reservation.reservable.name ) %></p>
<p><%= "#{I18n.l @attached_object.start_at, format: :long} - #{I18n.l @attached_object.end_at, format: :hour_minute}" %></p>
<p><%= "#{I18n.l(@attached_object.start_at, format: :long)} - #{I18n.l(@attached_object.end_at, format: :hour_minute)}" %></p>

View File

@ -1,5 +1,5 @@
<%= render 'notifications_mailer/shared/hello', recipient: @recipient %>
<p><%= t('.body.reservation_changed_to') %></p>
<p><%= "#{I18n.l @attached_object.start_at, format: :long} - #{I18n.l @attached_object.end_at, format: :hour_minute}" %></p>
<p><small><%= t('.body.previous_date') %> <%= "#{I18n.l @attached_object.ex_start_at, format: :long} - #{I18n.l @attached_object.ex_end_at, format: :hour_minute}" %></small></p>
<p><%= "#{I18n.l(@attached_object.start_at, format: :long)} - #{I18n.l(@attached_object.end_at, format: :hour_minute)}" %></p>
<p><small><%= t('.body.previous_date') %> <%= "#{I18n.l(@attached_object.ex_start_at, format: :long)} - #{I18n.l(@attached_object.ex_end_at, format: :hour_minute)}" %></small></p>

View File

@ -5,38 +5,49 @@
class PaymentScheduleItemWorker
include Sidekiq::Worker
def perform
PaymentScheduleItem.where(state: 'new').where('due_date < ?', DateTime.current).each do |psi|
# the following depends on the payment method (stripe/check)
if psi.payment_schedule.payment_method == 'stripe'
### Stripe
stripe_key = Setting.get('stripe_secret_key')
stp_suscription = Stripe::Subscription.retrieve(psi.payment_schedule.stp_subscription_id, api_key: stripe_key)
stp_invoice = Stripe::Invoice.retrieve(stp_suscription.latest_invoice, api_key: stripe_key)
if stp_invoice.status == 'paid'
##### Stripe / Successfully paid
PaymentScheduleService.new.generate_invoice(psi, stp_invoice)
psi.update_attributes(state: 'paid', payment_method: 'stripe', stp_invoice_id: stp_invoice.id)
elsif stp_suscription.status == 'past_due'
##### Stripe / Payment error
NotificationCenter.call type: 'notify_admin_payment_schedule_failed',
receiver: User.admins_and_managers,
attached_object: psi
NotificationCenter.call type: 'notify_member_payment_schedule_failed',
receiver: psi.payment_schedule.user,
attached_object: psi
stp_payment_intent = Stripe::PaymentIntent.retrieve(stp_invoice.payment_intent, api_key: stripe_key)
psi.update_attributes(state: stp_payment_intent.status, stp_invoice_id: stp_invoice.id)
else
psi.update_attributes(state: 'error')
end
else
### Check
NotificationCenter.call type: 'notify_admin_payment_schedule_check_deadline',
receiver: User.admins_and_managers,
attached_object: psi
psi.update_attributes(state: 'pending')
def perform(record_id = nil)
if record_id
psi = PaymentScheduleItem.find(record_id)
check_item(psi)
else
PaymentScheduleItem.where.not(state: 'paid').where('due_date < ?', DateTime.current).each do |psi|
check_item(psi)
end
end
end
def check_item(psi)
# the following depends on the payment method (stripe/check)
if psi.payment_schedule.payment_method == 'stripe'
### Stripe
stripe_key = Setting.get('stripe_secret_key')
stp_subscription = Stripe::Subscription.retrieve(psi.payment_schedule.stp_subscription_id, api_key: stripe_key)
stp_invoice = Stripe::Invoice.retrieve(stp_subscription.latest_invoice, api_key: stripe_key)
if stp_invoice.status == 'paid'
##### Stripe / Successfully paid
PaymentScheduleService.new.generate_invoice(psi, stp_invoice)
psi.update_attributes(state: 'paid', payment_method: 'stripe', stp_invoice_id: stp_invoice.id)
elsif stp_subscription.status == 'past_due' || stp_invoice.status == 'open'
##### Stripe / Payment error
NotificationCenter.call type: 'notify_admin_payment_schedule_failed',
receiver: User.admins_and_managers,
attached_object: psi
NotificationCenter.call type: 'notify_member_payment_schedule_failed',
receiver: psi.payment_schedule.user,
attached_object: psi
stp_payment_intent = Stripe::PaymentIntent.retrieve(stp_invoice.payment_intent, api_key: stripe_key)
psi.update_attributes(state: stp_payment_intent.status,
stp_invoice_id: stp_invoice.id,
client_secret: stp_payment_intent.client_secret)
else
psi.update_attributes(state: 'error')
end
else
### Check
NotificationCenter.call type: 'notify_admin_payment_schedule_check_deadline',
receiver: User.admins_and_managers,
attached_object: psi
psi.update_attributes(state: 'pending')
end
end
end

View File

@ -667,6 +667,8 @@ en:
confirm_check_cashing: "Confirm the cashing of the check"
confirm_check_cashing_body: "You must cash a check of {AMOUNT} for the deadline of {DATE}. By confirming the cashing of the check, an invoice will be generated for this due date."
confirm_button: "Confirm"
resolve_action: "Resolve the action"
ok_button: "OK"
document_filters:
reference: "Reference"
customer: "Customer"

View File

@ -667,6 +667,8 @@ fr:
confirm_check_cashing: "Confirmer l'encaissement du chèque"
confirm_check_cashing_body: "Vous devez encaisser un chèque de {AMOUNT} pour l'échéance du {DATE}. En confirmant l'encaissement du chèque, une facture sera générée pour cette échéance."
confirm_button: "Confirmer"
resolve_action: "Résoudre l'action"
ok_button: "OK"
document_filters:
reference: "Référence"
customer: "Client"

View File

@ -474,3 +474,7 @@ en:
what_to_do: "What do you want to do?"
tour: "Start the feature tour"
guide: "Open the user's manual"
# 2nd factor authentication for card payments
stripe_confirm:
pending: "Pending for action..."
success: "Thank you, your card setup is complete. The payment will be proceeded shortly."

View File

@ -474,3 +474,7 @@ fr:
what_to_do: "Que voulez-vous faire ?"
tour: "Lancer la visite guidée"
guide: "Ouvrir le manuel de l'utilisateur"
# 2nd factor authentication for card payments
stripe_confirm:
pending: "En attente de l'action ..."
success: "Merci, la configuration de votre carte est terminée. Le paiement sera effectué sous peu."

View File

@ -299,13 +299,14 @@ en:
body:
remember: "In accordance with the %{REFERENCE} payment schedule, a debit by card of %{AMOUNT} was scheduled on %{DATE}."
error: "Unfortunately, this card debit was unable to complete successfully."
action: "Please contact the member as soon as possible, and go to your payment schedule management interface to resolve the problem. After about 24 hours, the card subscription will be cancelled."
action: "Please contact the member as soon as possible, then go to the payment schedule management interface to resolve the problem. After about 24 hours, the card subscription will be cancelled."
notify_member_payment_schedule_failed:
subject: "[URGENT] Card debit failure"
body:
remember: "In accordance with your %{REFERENCE} payment schedule, a debit by card of %{AMOUNT} was scheduled on %{DATE}."
error: "Unfortunately, this card debit was unable to complete successfully."
action: "Please contact the manager of your FabLab before 24 hours, otherwise your subscription may be interrupted."
action: "Please check %{DASHBOARD} or contact a manager before 24 hours, otherwise your subscription may be interrupted."
your_dashboard: "your dashboard"
notify_admin_payment_schedule_check_deadline:
subject: "Payment deadline"
body:

View File

@ -299,13 +299,14 @@ fr:
body:
remember: "Conformément à l'échéancier de paiement %{REFERENCE}, un prélèvement par carte de %{AMOUNT} était prévu le %{DATE}."
error: "Malheureusement, ce prélèvement n'a pas pu être effectué correctement."
action: "Veuillez vous mettre en relation avec le membre au plus tôt, ainsi que vous rendre dans votre interface de gestion des échéanciers pour régler le problème. Au delà d'environ 24 heures, l'abonnement par carte bancaire sera annulé."
action: "Veuillez vous mettre en relation avec le membre au plus tôt, puis vous rendre dans l'interface de gestion des échéanciers afin de régler le problème. Au delà d'environ 24 heures, l'abonnement par carte bancaire sera annulé."
notify_member_payment_schedule_failed:
subject: "[URGENT] Échec du prélèvement par carte"
body:
remember: "Conformément à votre échéancier de paiement %{REFERENCE}, un prélèvement par carte de %{AMOUNT} était prévu le %{DATE}."
error: "Malheureusement, ce prélèvement n'a pas pu être effectué correctement."
action: "Veuillez prendre contact avec le gestionnaire de votre FabLab sous 24 heures, faute de quoi votre abonnement risque d'être interrompu."
action: "Veuillez vous rendre dans votre %{DASHBOARD} ou prendre contact avec un gestionnaire sous 24 heures, faute de quoi votre abonnement risque d'être interrompu."
your_dashboard: "votre tableau de bord"
notify_admin_payment_schedule_check_deadline:
subject: "Échéance d'encaissement"
body:

View File

@ -115,6 +115,7 @@ Rails.application.routes.draw do
post 'list', action: 'list', on: :collection
get 'download', on: :member
post 'items/:id/cash_check', action: 'cash_check', on: :collection
post 'items/:id/refresh_item', action: 'refresh_item', on: :collection
end
resources :i_calendar, only: %i[index create destroy] do

View File

@ -10,6 +10,7 @@ class CreatePaymentScheduleItems < ActiveRecord::Migration[5.2]
t.jsonb :details, default: '{}'
t.string :stp_invoice_id
t.string :payment_method
t.string :client_secret
t.belongs_to :payment_schedule, foreign_key: true
t.belongs_to :invoice, foreign_key: true
t.string :footprint

View File

@ -1474,6 +1474,7 @@ CREATE TABLE public.payment_schedule_items (
details jsonb DEFAULT '"{}"'::jsonb,
stp_invoice_id character varying,
payment_method character varying,
client_secret character varying,
payment_schedule_id bigint,
invoice_id bigint,
footprint character varying,