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

buy prepaid-pack a hours from modal

This commit is contained in:
Sylvain 2021-06-28 18:17:11 +02:00
parent 91d2316280
commit 1aad4891c1
23 changed files with 177 additions and 33 deletions

View File

@ -13,13 +13,14 @@ interface MachineCardProps {
onLoginRequested: () => Promise<User>,
onEnrollRequested: (trainingId: number) => void,
onError: (message: string) => void,
onSuccess: (message: string) => void,
}
/**
* This component is a box showing the picture of the given machine and two buttons: one to start the reservation process
* and another to redirect the user to the machine description page.
*/
const MachineCardComponent: React.FC<MachineCardProps> = ({ user, machine, onShowMachine, onReserveMachine, onError, onLoginRequested, onEnrollRequested }) => {
const MachineCardComponent: React.FC<MachineCardProps> = ({ user, machine, onShowMachine, onReserveMachine, onError, onSuccess, onLoginRequested, onEnrollRequested }) => {
const { t } = useTranslation('public');
// shall we display a loader to prevent double-clicking, while the machine details are loading?
@ -60,6 +61,7 @@ const MachineCardComponent: React.FC<MachineCardProps> = ({ user, machine, onSho
onLoadingStart={() => setLoading(true)}
onLoadingEnd={() => setLoading(false)}
onError={onError}
onSuccess={onSuccess}
onReserveMachine={handleReserveMachine}
onLoginRequested={onLoginRequested}
onEnrollRequested={onEnrollRequested}
@ -79,10 +81,10 @@ const MachineCardComponent: React.FC<MachineCardProps> = ({ user, machine, onSho
}
export const MachineCard: React.FC<MachineCardProps> = ({ user, machine, onShowMachine, onReserveMachine, onError, onLoginRequested, onEnrollRequested }) => {
export const MachineCard: React.FC<MachineCardProps> = ({ user, machine, onShowMachine, onReserveMachine, onError, onSuccess, onLoginRequested, onEnrollRequested }) => {
return (
<Loader>
<MachineCardComponent user={user} machine={machine} onShowMachine={onShowMachine} onReserveMachine={onReserveMachine} onError={onError} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} />
<MachineCardComponent user={user} machine={machine} onShowMachine={onShowMachine} onReserveMachine={onReserveMachine} onError={onError} onSuccess={onSuccess} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} />
</Loader>
);
}

View File

@ -13,6 +13,7 @@ declare var Application: IApplication;
interface MachinesListProps {
user?: User,
onError: (message: string) => void,
onSuccess: (message: string) => void,
onShowMachine: (machine: Machine) => void,
onReserveMachine: (machine: Machine) => void,
onLoginRequested: () => Promise<User>,
@ -22,7 +23,7 @@ interface MachinesListProps {
/**
* This component shows a list of all machines and allows filtering on that list.
*/
const MachinesList: React.FC<MachinesListProps> = ({ onError, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested, user }) => {
const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested, user }) => {
// shown machines
const [machines, setMachines] = useState<Array<Machine>>(null);
// we keep the full list of machines, for filtering
@ -65,6 +66,7 @@ const MachinesList: React.FC<MachinesListProps> = ({ onError, onShowMachine, onR
onShowMachine={onShowMachine}
onReserveMachine={onReserveMachine}
onError={onError}
onSuccess={onSuccess}
onLoginRequested={onLoginRequested}
onEnrollRequested={onEnrollRequested} />
})}
@ -74,12 +76,12 @@ const MachinesList: React.FC<MachinesListProps> = ({ onError, onShowMachine, onR
}
const MachinesListWrapper: React.FC<MachinesListProps> = ({ user, onError, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested }) => {
const MachinesListWrapper: React.FC<MachinesListProps> = ({ user, onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested }) => {
return (
<Loader>
<MachinesList user={user} onError={onError} onShowMachine={onShowMachine} onReserveMachine={onReserveMachine} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} />
<MachinesList user={user} onError={onError} onSuccess={onSuccess} onShowMachine={onShowMachine} onReserveMachine={onReserveMachine} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} />
</Loader>
);
}
Application.Components.component('machinesList', react2angular(MachinesListWrapper, ['user', 'onError', 'onShowMachine', 'onReserveMachine', 'onLoginRequested', 'onEnrollRequested']));
Application.Components.component('machinesList', react2angular(MachinesListWrapper, ['user', 'onError', 'onSuccess', 'onShowMachine', 'onReserveMachine', 'onLoginRequested', 'onEnrollRequested']));

View File

@ -9,6 +9,8 @@ import { IFablab } from '../../models/fablab';
import { FabButton } from '../base/fab-button';
import PriceAPI from '../../api/price';
import { Price } from '../../models/price';
import { PaymentMethod, ShoppingCart } from '../../models/payment';
import { PaymentModal } from '../payment/payment-modal';
declare var Fablab: IFablab;
@ -17,18 +19,22 @@ interface ProposePacksModalProps {
toggleModal: () => void,
machine: Machine,
customer: User,
operator: User,
onError: (message: string) => void,
onDecline: (machine: Machine) => void,
onSuccess: (message:string, machine: Machine) => void,
}
/**
* Modal dialog shown to offer prepaid-packs for purchase, to the current user.
*/
export const ProposePacksModal: React.FC<ProposePacksModalProps> = ({ isOpen, toggleModal, machine, customer, onError, onDecline }) => {
export const ProposePacksModal: React.FC<ProposePacksModalProps> = ({ isOpen, toggleModal, machine, customer, operator, onError, onDecline, onSuccess }) => {
const { t } = useTranslation('logged');
const [price, setPrice] = useState<Price>(null);
const [packs, setPacks] = useState<Array<PrepaidPack>>(null);
const [cart, setCart] = useState<ShoppingCart>(null);
const [paymentModal, setPaymentModal] = useState<boolean>(false);
useEffect(() => {
PrepaidPackAPI.index({ priceable_id: machine.id, priceable_type: 'Machine', group_id: customer.group_id, disabled: false })
@ -40,6 +46,13 @@ export const ProposePacksModal: React.FC<ProposePacksModalProps> = ({ isOpen, to
}, [machine]);
/**
* Open/closes the payment modal
*/
const togglePaymentModal = (): void => {
setPaymentModal(!paymentModal);
}
/**
* Return the formatted localized amount for the given price (e.g. 20.5 => "20,50 €")
*/
@ -78,14 +91,28 @@ export const ProposePacksModal: React.FC<ProposePacksModalProps> = ({ isOpen, to
}
/**
* The user has accepted to buy the provided pack, process with teh payment
* The user has accepted to buy the provided pack, process with the payment
*/
const handleBuyPack = (pack: PrepaidPack) => {
return (event: BaseSyntheticEvent): void => {
console.log(pack);
return (): void => {
setCart({
customer_id: customer.id,
payment_method: PaymentMethod.Card,
items: [
{ prepaid_pack: { id: pack.id }}
]
});
togglePaymentModal();
}
}
/**
* Callback triggered when the user has bought the pack with a successful payment
*/
const handlePackBought = (): void => {
onSuccess(t('app.logged.propose_packs_modal.pack_bought_success'), machine);
}
/**
* Render the given prepaid-pack
*/
@ -118,6 +145,13 @@ export const ProposePacksModal: React.FC<ProposePacksModalProps> = ({ isOpen, to
<div className="list-of-packs">
{packs?.map(p => renderPack(p))}
</div>
{cart && <PaymentModal isOpen={paymentModal}
toggleModal={togglePaymentModal}
afterSuccess={handlePackBought}
onError={onError}
cart={cart}
currentUser={operator}
customer={customer} />}
</FabModal>
);
}

View File

@ -18,6 +18,7 @@ interface ReserveButtonProps {
onLoadingStart?: () => void,
onLoadingEnd?: () => void,
onError: (message: string) => void,
onSuccess: (message: string) => void,
onReserveMachine: (machine: Machine) => void,
onLoginRequested: () => Promise<User>,
onEnrollRequested: (trainingId: number) => void,
@ -27,7 +28,7 @@ interface ReserveButtonProps {
/**
* Button component that makes the training verification before redirecting the user to the reservation calendar
*/
const ReserveButtonComponent: React.FC<ReserveButtonProps> = ({ currentUser, machineId, onLoginRequested, onLoadingStart, onLoadingEnd, onError, onReserveMachine, onEnrollRequested, className, children }) => {
const ReserveButtonComponent: React.FC<ReserveButtonProps> = ({ currentUser, machineId, onLoginRequested, onLoadingStart, onLoadingEnd, onError, onSuccess, onReserveMachine, onEnrollRequested, className, children }) => {
const { t } = useTranslation('shared');
const [machine, setMachine] = useState<Machine>(null);
@ -87,6 +88,15 @@ const ReserveButtonComponent: React.FC<ReserveButtonProps> = ({ currentUser, mac
setProposePacks(!proposePacks);
}
/**
* Callback triggered when the user has successfully bought a pre-paid pack.
* Display the success message and redirect him to the booking page.
*/
const handlePackBought = (message: string, machine: Machine): void => {
onSuccess(message);
onReserveMachine(machine);
}
/**
* Check that the current user has passed the required training before allowing him to book
*/
@ -154,20 +164,22 @@ const ReserveButtonComponent: React.FC<ReserveButtonProps> = ({ currentUser, mac
machine={machine}
onError={onError}
customer={currentUser}
onDecline={onReserveMachine} />}
onDecline={onReserveMachine}
operator={currentUser}
onSuccess={handlePackBought} />}
</span>
);
}
export const ReserveButton: React.FC<ReserveButtonProps> = ({ currentUser, machineId, onLoginRequested, onLoadingStart, onLoadingEnd, onError, onReserveMachine, onEnrollRequested, className, children }) => {
export const ReserveButton: React.FC<ReserveButtonProps> = ({ currentUser, machineId, onLoginRequested, onLoadingStart, onLoadingEnd, onError, onSuccess, onReserveMachine, onEnrollRequested, className, children }) => {
return (
<Loader>
<ReserveButtonComponent currentUser={currentUser} machineId={machineId} onError={onError} onLoadingStart={onLoadingStart} onLoadingEnd={onLoadingEnd} onReserveMachine={onReserveMachine} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} className={className}>
<ReserveButtonComponent currentUser={currentUser} machineId={machineId} onError={onError} onSuccess={onSuccess} onLoadingStart={onLoadingStart} onLoadingEnd={onLoadingEnd} onReserveMachine={onReserveMachine} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} className={className}>
{children}
</ReserveButtonComponent>
</Loader>
);
}
Application.Components.component('reserveButton', react2angular(ReserveButton, ['currentUser', 'machineId', 'onLoadingStart', 'onLoadingEnd', 'onError', 'onReserveMachine', 'onLoginRequested', 'onEnrollRequested', 'className']));
Application.Components.component('reserveButton', react2angular(ReserveButton, ['currentUser', 'machineId', 'onLoadingStart', 'onLoadingEnd', 'onError', 'onSuccess', 'onReserveMachine', 'onLoginRequested', 'onEnrollRequested', 'className']));

View File

@ -39,7 +39,7 @@ interface AbstractPaymentModalProps {
afterSuccess: (result: Invoice|PaymentSchedule) => void,
cart: ShoppingCart,
currentUser: User,
schedule: PaymentSchedule,
schedule?: PaymentSchedule,
customer: User,
logoFooter: ReactNode,
GatewayForm: FunctionComponent<GatewayFormProps>,

View File

@ -21,7 +21,7 @@ interface PaymentModalProps {
onError: (message: string) => void,
cart: ShoppingCart,
currentUser: User,
schedule: PaymentSchedule,
schedule?: PaymentSchedule,
customer: User
}
@ -29,7 +29,7 @@ interface PaymentModalProps {
* This component open a modal dialog for the configured payment gateway, allowing the user to input his card data
* to process an online payment.
*/
const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule , cart, customer }) => {
const PaymentModalComponent: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule , cart, customer }) => {
const { t } = useTranslation('shared');
const [gateway, setGateway] = useState<Setting>(null);
@ -88,12 +88,12 @@ const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterS
}
const PaymentModalWrapper: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule , cart, customer }) => {
export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule , cart, customer }) => {
return (
<Loader>
<PaymentModal isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} onError={onError} currentUser={currentUser} schedule={schedule} cart={cart} customer={customer} />
<PaymentModalComponent isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} onError={onError} currentUser={currentUser} schedule={schedule} cart={cart} customer={customer} />
</Loader>
);
}
Application.Components.component('paymentModal', react2angular(PaymentModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer']));
Application.Components.component('paymentModal', react2angular(PaymentModal, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer']));

View File

@ -17,7 +17,7 @@ import { Invoice } from '../../../models/invoice';
// we use these two additional parameters to update the card, if provided
interface PayzenFormProps extends GatewayFormProps {
updateCard?: boolean,
paymentScheduleId: number,
paymentScheduleId?: number,
}
/**

View File

@ -17,7 +17,7 @@ interface PayZenModalProps {
afterSuccess: (result: Invoice|PaymentSchedule) => void,
cart: ShoppingCart,
currentUser: User,
schedule: PaymentSchedule,
schedule?: PaymentSchedule,
customer: User
}

View File

@ -18,7 +18,7 @@ interface StripeModalProps {
afterSuccess: (result: Invoice|PaymentSchedule) => void,
cart: ShoppingCart,
currentUser: User,
schedule: PaymentSchedule,
schedule?: PaymentSchedule,
customer: User
}

View File

@ -114,6 +114,13 @@ Application.Controllers.controller('MachinesController', ['$scope', '$state', '_
growl.error(message);
}
/**
* Shows a success message forwarded from a child react components
*/
$scope.onSuccess = function (message) {
growl.success(message)
}
/**
* Open the modal dialog to log the user and resolves the returned promise when the logging process
* was successfully completed.
@ -311,6 +318,14 @@ Application.Controllers.controller('ShowMachineController', ['$scope', '$state',
growl.error(message);
}
/**
* Shows a success message forwarded from a child react components
*/
$scope.onSuccess = function (message) {
growl.success(message)
}
/**
* Open the modal dialog to log the user and resolves the returned promise when the logging process
* was successfully completed.

View File

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

View File

@ -43,6 +43,7 @@
<machines-list user="currentUser"
on-error="onError"
on-success="onSuccess"
on-show-machine="showMachine"
on-reserve-machine="reserveMachine"
on-login-requested="onLoginRequest"

View File

@ -20,6 +20,7 @@
current-user="currentUser"
machine-id="machine.id"
on-error="onError"
on-success="onSuccess"
on-reserve-machine="reserveMachine"
on-login-requested="onLoginRequest"
on-enroll-requested="onEnrollRequest">

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
# A prepaid-pack added to the shopping cart
class CartItem::PrepaidPack < CartItem::BaseItem
def initialize(pack, customer)
raise TypeError unless pack.is_a? PrepaidPack
@pack = pack
@customer = customer
super
end
def pack
raise InvalidGroupError if @pack.group_id != @customer.group_id
@pack
end
def price
amount = pack.amount
elements = { pack: amount }
{ elements: elements, amount: amount }
end
def name
"#{@pack.minutes / 60} h"
end
def to_object
::StatisticProfilePrepaidPack.new(
prepaid_pack_id: @pack.id,
statistic_profile_id: StatisticProfile.find_by(user: @customer).id
)
end
end

View File

@ -54,7 +54,7 @@ class Invoice < PaymentDocument
# for debug & used by rake task "fablab:maintenance:regenerate_invoices"
def regenerate_invoice_pdf
pdf = ::PDF::Invoice.new(self, invoice_items.find(&:subscription)&.expiration_date).render
pdf = ::PDF::Invoice.new(self, invoice_items.find_by(object_type: Subscription.name)&.expiration_date).render
File.binwrite(file, pdf)
end

View File

@ -12,6 +12,7 @@ class InvoiceItem < Footprintable
belongs_to :subscription, foreign_type: 'Subscription', foreign_key: 'object_id'
belongs_to :wallet_transaction, foreign_type: 'WalletTransaction', foreign_key: 'object_id'
belongs_to :offer_day, foreign_type: 'OfferDay', foreign_key: 'object_id'
belongs_to :statistic_profile_prepaid_pack, foreign_type: 'StatisticProfilePrepaidPack', foreign_key: 'object_id'
after_create :chain_record
after_update :log_changes

View File

@ -7,6 +7,7 @@ class PaymentScheduleObject < Footprintable
belongs_to :subscription, foreign_type: 'Subscription', foreign_key: 'object_id'
belongs_to :wallet_transaction, foreign_type: 'WalletTransaction', foreign_key: 'object_id'
belongs_to :offer_day, foreign_type: 'OfferDay', foreign_key: 'object_id'
belongs_to :statistic_profile_prepaid_pack, foreign_type: 'StatisticProfilePrepaidPack', foreign_key: 'object_id'
belongs_to :payment_schedule
after_create :chain_record

View File

@ -5,4 +5,15 @@
class StatisticProfilePrepaidPack < ApplicationRecord
belongs_to :prepaid_pack
belongs_to :statistic_profile
has_many :invoice_items, as: :object, dependent: :destroy
has_one :payment_schedule_object, as: :object, dependent: :destroy
before_create :set_expiration_date
private
def set_expiration_date
self.expires_at = DateTime.current + prepaid_pack.validity
end
end

View File

@ -110,6 +110,8 @@ class PDF::Invoice < Prawn::Document
object = offer_day_verbose(invoice.main_item.object, name)
when 'Error'
object = I18n.t('invoices.error_invoice')
when 'StatisticProfilePrepaidPack'
object = I18n.t('invoices.prepaid_pack')
else
puts "ERROR : specified main_item.object_type type (#{invoice.main_item.object_type}) is unknown"
end
@ -132,7 +134,7 @@ class PDF::Invoice < Prawn::Document
details = invoice.is_a?(Avoir) ? I18n.t('invoices.cancellation') + ' - ' : ''
if item.subscription ### Subscription
if item.object_type == Subscription.name
subscription = item.subscription
if invoice.main_item.object_type == 'OfferDay'
details += I18n.t('invoices.subscription_extended_for_free_from_START_to_END',
@ -154,7 +156,7 @@ class PDF::Invoice < Prawn::Document
end
else ### Reservation
elsif item.object_type == Reservation.name
case invoice.main_item.object.try(:reservable_type)
### Machine reservation
when 'Machine'
@ -179,6 +181,8 @@ class PDF::Invoice < Prawn::Document
else
details += item.description
end
else
details += item.description
end
data += [[details, number_to_currency(price)]]

View File

@ -20,6 +20,8 @@ class CartService
items.push(CartItem::Subscription.new(plan_info[:plan], @customer)) 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))
end
end
@ -45,10 +47,12 @@ class CartService
items = []
payment_schedule.payment_schedule_objects.each do |object|
if object.subscription
if object.object_type == Subscription.name
items.push(CartItem::Subscription.new(object.subscription.plan, @customer))
elsif object.reservation
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))
end
end

View File

@ -64,7 +64,7 @@ class InvoicesService
# Create an Invoice with an associated array of InvoiceItem matching the given parameters
# @param payment_details {Hash} as generated by ShoppingCart.total
# @param operator_profile_id {Number} ID of the user that operates the invoice generation (may be an admin, a manager or the customer himself)
# @param objects {Array<Reservation|Subscription>} the booking reservation and/or subscription
# @param objects {Array<Reservation|Subscription|StatisticProfilePrepaidPack>} the booked reservation and/or subscription or pack
# @param user {User} the customer
# @param payment_id {String} ID of the payment, a returned by the gateway, if the current invoice is paid by card
# @param payment_method {String} the payment method used
@ -96,7 +96,7 @@ class InvoicesService
# Generate an array of {InvoiceItem} with the elements in provided reservation, price included.
# @param invoice {Invoice} the parent invoice
# @param payment_details {Hash} as generated by ShoppingCart.total
# @param objects {Array<Reservation|Subscription>}
# @param objects {Array<Reservation|Subscription|StatisticProfilePrepaidPack>}
##
def self.generate_invoice_items(invoice, payment_details, objects)
objects.each_with_index do |object, index|
@ -106,6 +106,8 @@ class InvoicesService
InvoicesService.generate_subscription_item(invoice, object, payment_details, index.zero?)
elsif object.is_a?(Reservation)
InvoicesService.generate_reservation_item(invoice, object, payment_details, index.zero?)
elsif object.is_a?(StatisticProfilePrepaidPack)
InvoicesService.generate_prepaid_pack_item(invoice, object, payment_details, index.zero?)
else
InvoicesService.generate_generic_item(invoice, object, payment_details, index.zero?)
end
@ -179,6 +181,21 @@ class InvoicesService
)
end
##
# Generate an InvoiceItem for the given StatisticProfilePrepaidPack and save it in invoice.invoice_items.
# This method must be called only with a valid pack-statistic_profile relation
##
def self.generate_prepaid_pack_item(invoice, pack, payment_details, main = false)
raise TypeError unless pack
invoice.invoice_items.push InvoiceItem.new(
amount: payment_details[:elements][:pack],
description: I18n.t('invoices.pack_item', COUNT: pack.prepaid_pack.minutes / 60),
object: pack,
main: main
)
end
def self.generate_generic_item(invoice, item, payment_details, main = false)
invoice.invoice_items.push InvoiceItem.new(
amount: payment_details[:elements][item.class.name.to_sym],

View File

@ -184,6 +184,7 @@ en:
no_thanks: "No, thanks"
pack_DURATION: "{DURATION} hours"
buy_this_pack: "Buy this pack"
pack_bought_success: "You have successfully bought this pack of prepaid-hours. Your invoice will ba available soon from your dashboard."
validity: "Usable for {COUNT} {PERIODS}"
period:
day: "{COUNT, plural, one{day} other{days}}"

View File

@ -113,6 +113,8 @@ en:
and: 'and'
invoice_text_example: "Our association is not subject to VAT"
error_invoice: "Erroneous invoice. The items below ware not booked. Please contact the FabLab for a refund."
prepaid_pack: "Prepaid pack of hours"
pack_item: "Pack of %{COUNT} hours"
#PDF payment schedule generation
payment_schedules:
schedule_reference: "Payment schedule reference: %{REF}"