mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2025-02-19 13:54:25 +01:00
WIP: error handling for card payments on later deadlines
This commit is contained in:
parent
1a93aadda0
commit
163976b988
@ -22,6 +22,9 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
|
||||
|
||||
const [showExpanded, setShowExpanded] = useState({});
|
||||
|
||||
/**
|
||||
* Check if the requested payment schedule is displayed with its deadlines (PaymentScheduleItem) or without them
|
||||
*/
|
||||
const isExpanded = (paymentScheduleId: number): boolean => {
|
||||
return showExpanded[paymentScheduleId];
|
||||
}
|
||||
@ -39,6 +42,9 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
|
||||
return new Intl.NumberFormat(Fablab.intl_locale, {style: 'currency', currency: Fablab.intl_currency}).format(price);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the value for the CSS property 'display', for the payment schedule deadlines
|
||||
*/
|
||||
const statusDisplay = (paymentScheduleId: number): string => {
|
||||
if (isExpanded(paymentScheduleId)) {
|
||||
return 'table-row'
|
||||
@ -47,6 +53,9 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the action icon for showing/hiding the deadlines
|
||||
*/
|
||||
const expandCollapseIcon = (paymentScheduleId: number): JSX.Element => {
|
||||
if (isExpanded(paymentScheduleId)) {
|
||||
return <i className="fas fa-minus-square" />;
|
||||
@ -55,6 +64,9 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide the deadlines for the provided payment schedule, inverting their current status
|
||||
*/
|
||||
const togglePaymentScheduleDetails = (paymentScheduleId: number): ReactEventHandler => {
|
||||
return (): void => {
|
||||
if (isExpanded(paymentScheduleId)) {
|
||||
@ -65,10 +77,17 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For use with downloadButton()
|
||||
*/
|
||||
enum TargetType {
|
||||
Invoice = 'invoices',
|
||||
PaymentSchedule = 'payment_schedules'
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a button to download a PDF file, may be an invoice, or a payment schedule, depending or the provided parameters
|
||||
*/
|
||||
const downloadButton = (target: TargetType, id: number): JSX.Element => {
|
||||
const link = `api/${target}/${id}/download`;
|
||||
return (
|
||||
@ -79,81 +98,147 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the human-readable string for the status of the provided deadline.
|
||||
*/
|
||||
const formatState = (item: PaymentScheduleItem): JSX.Element => {
|
||||
let res = t(`app.admin.invoices.schedules_table.state_${item.state}`);
|
||||
if (item.state === PaymentScheduleItemState.Paid) {
|
||||
res += ` (${item.payment_method})`;
|
||||
const key = `app.admin.invoices.schedules_table.method_${item.payment_method}`
|
||||
res += ` (${t(key)})`;
|
||||
}
|
||||
return <span className={`state-${item.state}`}>{res}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the action button(s) for the given deadline
|
||||
*/
|
||||
const itemButtons = (item: PaymentScheduleItem): JSX.Element => {
|
||||
switch (item.state) {
|
||||
case PaymentScheduleItemState.Paid:
|
||||
return downloadButton(TargetType.Invoice, item.invoice_id);
|
||||
case PaymentScheduleItemState.Pending:
|
||||
return (<span><button>encaisser le chèque</button><button>réessayer (stripe)</button></span>);
|
||||
return (
|
||||
<button className="action-button" onClick={handleConfirmCheckPayment(item)}>
|
||||
<i className="fas fa-money-check" />
|
||||
{t('app.admin.invoices.schedules_table.confirm_payment')}
|
||||
</button>
|
||||
);
|
||||
case PaymentScheduleItemState.RequireAction:
|
||||
return (
|
||||
<button className="action-button" onClick={handleSolveAction(item)}>
|
||||
<i className="fas fa-wrench" />
|
||||
{t('app.admin.invoices.schedules_table.solve')}
|
||||
</button>
|
||||
);
|
||||
case PaymentScheduleItemState.RequirePaymentMethod:
|
||||
return (
|
||||
<button className="action-button" onClick={handleUpdateCard(item)}>
|
||||
<i className="fas fa-credit-card" />
|
||||
{t('app.admin.invoices.schedules_table.update_card')}
|
||||
</button>
|
||||
);
|
||||
default:
|
||||
return <span />
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmCheckPayment = (item: PaymentScheduleItem): ReactEventHandler => {
|
||||
return (): void => {
|
||||
/*
|
||||
TODO
|
||||
- display confirmation modal
|
||||
- create /api/payment_schedule/item/confirm_check endpoint and post to it
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
}
|
||||
});
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateCard = (item: PaymentScheduleItem): ReactEventHandler => {
|
||||
return (): void => {
|
||||
/*
|
||||
TODO
|
||||
- Notify the customer, collect new payment information, and create a new payment method
|
||||
- Attach the payment method to the customer
|
||||
- Update the default payment method
|
||||
- Pay the invoice using the new payment method
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="schedules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-35" />
|
||||
<th className="w-200">{t('app.admin.invoices.schedules_table.schedule_num')}</th>
|
||||
<th className="w-200">{t('app.admin.invoices.schedules_table.date')}</th>
|
||||
<th className="w-120">{t('app.admin.invoices.schedules_table.price')}</th>
|
||||
{showCustomer && <th className="w-200">{t('app.admin.invoices.schedules_table.customer')}</th>}
|
||||
<th className="w-200"/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paymentSchedules.map(p => <tr key={p.id}>
|
||||
<td colSpan={6}>
|
||||
<table className="schedules-table-body">
|
||||
<tbody>
|
||||
<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">{formatDate(p.created_at)}</td>
|
||||
<td className="w-120">{formatPrice(p.total)}</td>
|
||||
{showCustomer && <td className="w-200">{p.user.name}</td>}
|
||||
<td className="w-200">{downloadButton(TargetType.PaymentSchedule, p.id)}</td>
|
||||
</tr>
|
||||
<tr style={{ display: statusDisplay(p.id) }}>
|
||||
<td className="w-35" />
|
||||
<td colSpan={5}>
|
||||
<div>
|
||||
<table className="schedule-items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-120">{t('app.admin.invoices.schedules_table.deadline')}</th>
|
||||
<th className="w-120">{t('app.admin.invoices.schedules_table.amount')}</th>
|
||||
<th className="w-200">{t('app.admin.invoices.schedules_table.state')}</th>
|
||||
<th className="w-200" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{_.orderBy(p.items, 'due_date').map(item => <tr key={item.id}>
|
||||
<td>{formatDate(item.due_date)}</td>
|
||||
<td>{formatPrice(item.amount)}</td>
|
||||
<td>{formatState(item)}</td>
|
||||
<td>{itemButtons(item)}</td>
|
||||
</tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div>
|
||||
<table className="schedules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-35" />
|
||||
<th className="w-200">{t('app.admin.invoices.schedules_table.schedule_num')}</th>
|
||||
<th className="w-200">{t('app.admin.invoices.schedules_table.date')}</th>
|
||||
<th className="w-120">{t('app.admin.invoices.schedules_table.price')}</th>
|
||||
{showCustomer && <th className="w-200">{t('app.admin.invoices.schedules_table.customer')}</th>}
|
||||
<th className="w-200"/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paymentSchedules.map(p => <tr key={p.id}>
|
||||
<td colSpan={6}>
|
||||
<table className="schedules-table-body">
|
||||
<tbody>
|
||||
<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">{formatDate(p.created_at)}</td>
|
||||
<td className="w-120">{formatPrice(p.total)}</td>
|
||||
{showCustomer && <td className="w-200">{p.user.name}</td>}
|
||||
<td className="w-200">{downloadButton(TargetType.PaymentSchedule, p.id)}</td>
|
||||
</tr>
|
||||
<tr style={{ display: statusDisplay(p.id) }}>
|
||||
<td className="w-35" />
|
||||
<td colSpan={5}>
|
||||
<div>
|
||||
<table className="schedule-items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-120">{t('app.admin.invoices.schedules_table.deadline')}</th>
|
||||
<th className="w-120">{t('app.admin.invoices.schedules_table.amount')}</th>
|
||||
<th className="w-200">{t('app.admin.invoices.schedules_table.state')}</th>
|
||||
<th className="w-200" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{_.orderBy(p.items, 'due_date').map(item => <tr key={item.id}>
|
||||
<td>{formatDate(item.due_date)}</td>
|
||||
<td>{formatPrice(item.amount)}</td>
|
||||
<td>{formatState(item)}</td>
|
||||
<td>{itemButtons(item)}</td>
|
||||
</tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
PaymentSchedulesTableComponent.defaultProps = { showCustomer: false };
|
||||
|
@ -1,7 +1,15 @@
|
||||
export enum PaymentScheduleItemState {
|
||||
New = 'new',
|
||||
Pending = 'pending',
|
||||
Paid = 'paid'
|
||||
RequirePaymentMethod = 'requires_payment_method',
|
||||
RequireAction = 'requires_action',
|
||||
Paid = 'paid',
|
||||
Error = 'error'
|
||||
}
|
||||
|
||||
export enum PaymentMethod {
|
||||
Stripe = 'stripe',
|
||||
Check = 'check'
|
||||
}
|
||||
export interface PaymentScheduleItem {
|
||||
id: number,
|
||||
@ -9,7 +17,8 @@ export interface PaymentScheduleItem {
|
||||
due_date: Date,
|
||||
state: PaymentScheduleItemState,
|
||||
invoice_id: number,
|
||||
payment_method: string,
|
||||
payment_method: PaymentMethod,
|
||||
client_secret?: string,
|
||||
details: {
|
||||
recurring: number,
|
||||
adjustment: number,
|
||||
|
@ -99,7 +99,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.download-button {
|
||||
.download-button,
|
||||
.action-button {
|
||||
color: black;
|
||||
background-color: #fbfbfb;
|
||||
display: inline-block;
|
||||
@ -139,13 +140,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
// The color classes above are automatically generated from PaymentScheduleItem.state
|
||||
.state-new {
|
||||
color: #3a3a3a;
|
||||
}
|
||||
.state-pending {
|
||||
.state-pending,
|
||||
.state-requires_payment_method,
|
||||
.state-requires_action {
|
||||
color: #d43333;
|
||||
}
|
||||
.state-paid {
|
||||
.state-paid,
|
||||
.state-error {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,14 @@ class PaymentScheduleItem < Footprintable
|
||||
payment_schedule.ordered_items.first == self
|
||||
end
|
||||
|
||||
def payment_intent
|
||||
return unless stp_invoice_id
|
||||
|
||||
key = Setting.get('stripe_secret_key')
|
||||
stp_invoice = Stripe::Invoice.retrieve(stp_invoice_id, api_key: key)
|
||||
Stripe::PaymentIntent.retrieve(stp_invoice.payment_intent, api_key: key)
|
||||
end
|
||||
|
||||
def self.columns_out_of_footprint
|
||||
%w[invoice_id stp_invoice_id state payment_method]
|
||||
end
|
||||
|
@ -98,7 +98,7 @@ class PaymentScheduleService
|
||||
|
||||
# save the results
|
||||
invoice.save
|
||||
payment_schedule_item.update_attributes(invoice_id: invoice.id, stp_invoice_id: stp_invoice&.id)
|
||||
payment_schedule_item.update_attributes(invoice_id: invoice.id)
|
||||
end
|
||||
|
||||
##
|
||||
|
@ -16,5 +16,6 @@ json.array! @payment_schedules do |ps|
|
||||
json.items ps.payment_schedule_items do |item|
|
||||
json.extract! item, :id, :due_date, :state, :invoice_id, :payment_method
|
||||
json.amount item.amount / 100.00
|
||||
json.client_secret item.payment_intent.client_secret if item.stp_invoice_id && item.state == 'requires_action'
|
||||
end
|
||||
end
|
||||
|
@ -16,8 +16,8 @@ class PaymentScheduleItemWorker
|
||||
if stp_invoice.status == 'paid'
|
||||
##### Stripe / Successfully paid
|
||||
PaymentScheduleService.new.generate_invoice(psi, stp_invoice)
|
||||
psi.update_attributes(state: 'paid', payment_method: 'stripe')
|
||||
else
|
||||
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,
|
||||
@ -25,7 +25,10 @@ class PaymentScheduleItemWorker
|
||||
NotificationCenter.call type: 'notify_member_payment_schedule_failed',
|
||||
receiver: psi.payment_schedule.user,
|
||||
attached_object: psi
|
||||
psi.update_attributes(state: 'pending')
|
||||
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
|
||||
|
@ -652,8 +652,16 @@ en:
|
||||
state: "State"
|
||||
download: "Download"
|
||||
state_new: "Not yet due"
|
||||
state_pending: "Action required"
|
||||
state_pending: "Waiting for the cashing of the check"
|
||||
state_requires_payment_method: "The credit card must be updated"
|
||||
state_requires_action: "Action required"
|
||||
state_paid: "Paid"
|
||||
state_error: "Error"
|
||||
method_stripe: "by card"
|
||||
method_check: "by check"
|
||||
confirm_payment: "Confirm payment"
|
||||
solve: "Solve"
|
||||
update_card: "Update the card"
|
||||
document_filters:
|
||||
reference: "Reference"
|
||||
customer: "Customer"
|
||||
|
@ -652,8 +652,16 @@ fr:
|
||||
state: "État"
|
||||
download: "Télécharger"
|
||||
state_new: "Pas encore à l'échéance"
|
||||
state_pending: "Action requise"
|
||||
state_pending: "En attente de l'encaissement du chèque"
|
||||
state_requires_payment_method: "La carte bancaire doit être mise à jour"
|
||||
state_requires_action: "Action requise"
|
||||
state_paid: "Payée"
|
||||
state_error: "Erreur"
|
||||
method_stripe: "par carte"
|
||||
method_check: "par chèque"
|
||||
confirm_payment: "Confirmer l'encaissement"
|
||||
solve: "Résoudre"
|
||||
update_card: "Mettre à jour la carte"
|
||||
document_filters:
|
||||
reference: "Référence"
|
||||
customer: "Client"
|
||||
|
Loading…
x
Reference in New Issue
Block a user