Table of Contents | ||
---|---|---|
|
...
This approach keeps the payment "simple" and consistent with the other transaction APIs. The storage layer performs some aggregation in the database of payments/credits based on the sourceInvoiceId specified in each payment. The flow looks something like this:
Introduce new tables:
- invoice_transaction_summary - Contains an invoiceId and the total number of transactions (payments/credits) expected when "paying" this invoice.
- temp_invoice_payments - Temporary storage of payments until all payments related to the invoice are received.
When an invoice is paid
- mod-invoice calls POST /finance/invoice-transaction-summary
- mod-finance calls mod-finance-storage to create the entry if one doesn't already exist
- mod-invoice generates payments and calls POST /finance/payments
- mod-finance checks calls POST /finance-storage/payments
When an payment is received,
- Persist the payment record in a temp_invoice_payments table
- Count the number of records in the temp_invoice_payments table. If this is the last payment, (the number of entries in the temp table == numTransactions)
- In a database transaction:
- Apply the payments/credits from the temp_invoice_payments table and perform the summary # updates (e.g. update budget, ledger, encumbrances, etc. - unavailable/available/encumbered/expended/etc.)
- If everything works, cleanup the temp_invoice_payments table and return 200 OK
- If something fails, no actual records are updated. The payment API call can fail and can be retried (POST payment/credit are idempotent)
- Apply the payments/credits from the temp_invoice_payments table and perform the summary # updates (e.g. update budget, ledger, encumbrances, etc. - unavailable/available/encumbered/expended/etc.)
- In a database transaction:
When an "approved" invoice is removed:
- mod-invoice makes a call to mod-finance to ensure that the invoice_transaction_summary has been cleaned up. These records are small so even if on is orphaned (which shouldn't happen often if ever) it's not a huge deal.
- DELETE /finance-storage/invoice-transaction-summary/<invoiceId>
If a payment request fails
- mod-finance can safely retry it.
- After N failed tries an error can be returned to mod-invoice and/or the client who can later try to pay the invoice again once the (network?) problem is resolved.
...
- if awaitingPayment.encumbranceId specified
- ALWAYS DO THIS PART:
- Update Budget identified by the transaction fiscal year (fiscalYearId) and the source fund (fromFundId)
- encumbered decreases by the transaction.amount
- awaitingPayment increases by the same amount
- update related encumbrance:
- transaction(encumbrance).amount is updated to (transaction(encumbrance.)amount - transaction(pending payment).amount)
- if pendingPayment.amout > encumbrance.amount
- Update the budget identified by the transaction(encumbrance) fiscal year (fiscalYearId) and the source fund (fromFundId)
- available decreases by the pendingPayment.amout - encumbrance.amount
unavailable increases by the pendingPayment.amout - encumbrance.amount (min 0)recalculate overExpended?
- Update the budget identified by the transaction(encumbrance) fiscal year (fiscalYearId) and the source fund (fromFundId)
- Update Budget identified by the transaction fiscal year (fiscalYearId) and the source fund (fromFundId)
- IN ADDITION TO ABOVE, DO THIS PART IF ENCUMBRANCE IS RELEASED:
- If encumbrance.status = Released
- Update the related encumbrance
- transaction.amount becomes 0 (save the original value for updating the budget)
- Update the budget identified by the transaction(encumbrance) fiscal year (fiscalYearId) and the source fund (fromFundId)
- encumbered decreases by the amount being released
- available increases by the amount being released
unavailable decreases by the amount being released (min 0)
Update LedgerFY identified by the transactions fiscal year (fiscalYearId) and source fund (fromFundId)available increases by the transaction amount (min 0)unavailable decreases by the transaction amount
- Update the related encumbrance
- If encumbrance.status = Released
- ALWAYS DO THIS PART:
- if awaitingPayment.encumbranceId is empty
- Update budget identified by the transaction fiscal year (fiscalYearId) and the source fund (fromFundId)
- available decreases by transaction amount (min 0)
unavailable increases by the transaction amount- awaitingPayment increases by the transaction.amount
Update LedgerFY identified by the transactions fiscal year (fiscalYearId) and destination fund (toFundId)available increases by the transaction amountunavailable decreases by the transaction amount (min 0)
- Update budget identified by the transaction fiscal year (fiscalYearId) and the source fund (fromFundId)
...
Method | Path | Request | Response | Description | Interface | Notes |
---|---|---|---|---|---|---|
POST | /finance/ledgers | ledger | ledger | Create a ledger | finance.ledgers | |
GET | /finance/ledgers/<id> | Fiscal Year Query (opt) | ledger | GET ledger by Id | finance.ledgers | If FY specified:Queries GET /finance-storage/ledger-fiscal-year
else
The fiscalYear query arg is the UUID of a fiscal-year record |
GET | /finance/ledgers | CQL Query Fiscal Year Query (opt) | collection<ledger> | Search/List ledgers | finance.ledgers | If FY specified:
else
The fiscalYear query arg is the UUID of a fiscal-year record |
PUT | /finance/ledgers/<id> | ledger | ledger | Update a ledger | finance.ledgers | |
DELETE | /finance/ledgers/<id> | NA | 204 | Delete a ledger | finance.ledgers | |
|
| |||||
|
| |||||
GET | /finance/ledgers/<id>/current-fiscal-year | NA | fiscal_year | Get the current fiscal year for a given ledger record with <id> | finance.ledgers |
NOTE: Removing GET /finance/ledgers/<id>/budgets. This functionality will be provided by GET /finance/budgets?query=fiscalYearId=ABC and ledger.id=DEF and ledger.acqUnits=\"XYZ\"
...
As you can see above, there is not API for directly manipulating the LedgerFY records. These records are automatically updated by the storage module when related changes are made.
- When a Ledger is created,
- create a LedgerFY record for the FY identified by ledger.fiscalYearOneId
- create a fiscalYear for the next period in the fiscalYear series (TODO: more details)
- create a LedgerFY record for the FY just created
When a FiscalYear is created, create a new LedgerFY record for each existing Ledger.(only if fiscalYear endDate > now)Handle this during rollover...
The approach we're taking will result in LedgerFY records being creating which may never be used - this is especially true in cases where there are simultaneous or overlapping fiscal years, e.g. Texas Fiscal Year (TFY19) and Qatar Fisal Year (QFY19) might both have similar or overlapping periods, but apply to two separate campuses, each with their own finances. A given ledger would only really apply to one or the other, but LedgerFY records would be created for both. We need to do this to support rollover. The LedgerFY records themselves are quite small, so it shouldn't be too much of a problem.
For now we don't have to worry about managing LedgerFY records upon update or delete of a ledger or FiscalYear. The plan is to implement a soft delete on both so reference integrity should be maintained without intervention.
...
Property | Type | Default | Required | Updated On Transaction | Notes | Requirements tickets | ||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
id | string | N | N | UUID - system generated if not specified | ||||||||||
name | string | Y | N | = <fund.code>-<fiscal_year.code>, eg. AFRICAHIST-FY19. Unique | ||||||||||
budgetStatus | string | Y | N | enum: Active, Frozen, Planned, Closed | ||||||||||
allowableEncumbrance | number | N | N | percentage | ||||||||||
allowableExpenditure | number | N | N | percentage | ||||||||||
initialAllocation | number | N | Y | First allocation transaction amount | ||||||||||
allocationTo | number | N | Y | Total of allocations to this budget | ||||||||||
allocationFrom | number | N | Y | Total of allocations from this budget | ||||||||||
allocated | number | 0 | N | Y | Running total of all the allocation transactions against the budget. = initialAllocation + allocationTo - allocationFrom | |||||||||
totalFunding | number | N | Y | = allocated + netTransfers | ||||||||||
cashBalance | number | N | Y | = totalFunding - expenditures | ||||||||||
awaitingPayment | number | N | Y (encumbrance only) | Running total of all the invoiced amounts (i.e. waiting to be paid) | ||||||||||
available | number | 0 | N | Y | = max(totalFunding - unavailable) NOTE: Doesn't include allowableEncumbered amount | |||||||||
encumbered | number | N | Y (encumbrance only) | Running total of money set aside for purchases | ||||||||||
expenditures | number | 0 | N | Y (encumbrance only) | Running total of all the payments recorded by the fund | |||||||||
unavailable | number | N | Y | = encumbered + awaitingPayment + expenditures NOTE: Doesn't include overEncumbered amount | ||||||||||
overEncumbrance | number | N | Y (encumbrance only) | Amount the budget is over encumbered. i.e. = max(encumbered - max(max(totalFunding - expenditures, 0) - awaytingPayment, 0), 0) | ||||||||||
overExpended | number | N | Y (encumbrance only) | Amount the budget is over expended. i.e. overExpended = max(awaytingPayment + expenditures - totalFunding, 0) | ||||||||||
netTransfers | number | 0 | N | Y | This would actually be calculated by summing all the "Transfers" on this budget. |
| ||||||||
fundId | string | Y | N | UUID of fund | ||||||||||
fiscalYearId | string | Y | N | UUID of fiscal year | ||||||||||
ledgerId | string | Y | N | UUID of ledger | ||||||||||
acquisitionUnits | array<string> | N | N | Array of UUIDs corresponding to the acquisition units assigned to this budget. | ||||||||||
tags | tags | N | N | inherited from fund | ||||||||||
metadata | metadata | N | Y | System generated metadata (createdBy/updatedBy/createdOn/etc.) |
...
Property | Type | Default | Required | Unique | Notes | Requirements tickets | ||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
id | string | N | Y | UUID - system generated if not specified |
| |||||||||
name | string | Y | Y | |||||||||||
code | string | N | Y | |||||||||||
externalAccountNumberExt | string | N | N |
...
Method | Path | Request | Response | Description | Interface | Notes | ||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
POST | /finance/expense-classes | expense-class | expense_class | Create a expense_class | finance.expense_class |
| ||||||||
GET | /finance/expense-classes/<id> | NA | expense_class | GET expense_class by Id | finance.expense_class | |||||||||
GET | /finance/expense-classes | CQL Query | collection<expense_class> | Search/List expense_class | finance.expense_class | |||||||||
PUT | /finance/expense-classes/<id> | expense_class | expense_class | Update a expense_class | finance.expense_class | |||||||||
DELETE | /finance/expense-classes/<id> | NA | 204 | Delete a expense_class | finance.expense_class |
...
Method | Path | Request | Response | Description | Interface | Notes | ||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
POST | /finance-storage/expense-classes | expense_class | expense_class | Create a expense_class | finance-storage.expense_classes |
| ||||||||
GET | /finance-storage/expense-classes/<id> | NA | expense_class | GET expense_class by Id | finance-storage.expense_classes | |||||||||
GET | /finance-storage/expense-classes | CQL Query | collection<expense_class> | Search/List expense_class | finance-storage.expense_classes | |||||||||
PUT | /finance-storage/expense-classes/<id> | expense_class | expense_class | Update a expense_class | finance-storage.expense_classes | |||||||||
DELETE | /finance-storage/expense-classes/<id> | NA | 204 | Delete a expense_class | finance-storage.expense_classes |
...
N.B. This was written prior to
. These views are no longer needed in light of this new cross-index subquery functionality. I'm leaving this section as-is in case this approach does not prove adequate for our needs. Jira Legacy server FOLIO Issue TrackerSystem Jira serverId 6ccf3fe401505d01-3301b853-368a3c2e-983e90f1-20c466b11a49ee9b165564fc key RMB-395
...
- The set of columns to show in search results has not been reviewed by the small group yet. That could potentially affect this if there are changes.
- How to express allocateTo/allocateFrom "all" funds? none? groups?
- It was discussed that we might at some point add separate fields for specifying groups of funds that a fund can allocatedTo/From. At the moment this is the best idea we've come up with. One potential downside is that it might encourage proliferation of groups which will have a negative impact on performance in the current design due to needing to perform group calculations on the fly.
- How to capture changes in currency? Historically?
- No need to track the currency code in each fund, but when the fiscalYear ends, we should capture the system currency in use at the time.
- Displaying search results with different currencies.... e.g. FY18 is in USD, FY19 is in CAD - Is this possible? Something like:
- Discussed with the FE guys, and this shouldn't be a problem.
Appendix
Preventing Partially Paid Invoices (deprecated method)
When an invoice is paid, numerous transactions (payments, credits) will be generated. Either all or none of these need to be successfully processed; we can't have partially paid invoices. This presents a challenge of scale. There could feasibly be hundreds of payments for an invoice.
...
This approach keeps the payment "simple" and consistent with the other transaction APIs. The storage layer performs some aggregation in the database of payments/credits based on the sourceInvoiceId specified in each payment. The flow looks something like this:
Introduce a new type of record... let's just call it a "saga" for now - what's proposed here isn't strictly a saga, but it's similar in ways. Maybe a "paymentManifest" or something is a better name; anyway - its not important right now.
Saga:
- invoiceId (UUID of the invoice these payments/credits are related to)
- numTransactions (int - total number of payments/credits)A payment or credit is posted
Flow:
- Using the sourceInvoiceId, get the invoiceLines and sum up the total number of fundDistributions - this is numTransactions.
- Get the saga using payments sourceInvoiceId. If one does exist, create one.
- Update the saga record and persist the payment record in a temporary table - do all this in a database transaction
- If this is the last payment in the saga, (the number of entries in the temp table == numTransactions) - NOTE: we'll probably want to use a unique_index on the id field or something to ensure the same payment isn't being counted more than once.
- Persist the payments/credits from the temporary table and perform the summary # updates (e.g. update budget, ledger, encumbrances, etc. - unavailable/available/encumbered/expended/etc.) - do this in a database transaction
- If everything works, cleanup the temporary table and return 200 OK
- If something fails, no actual records are updated. The payment API call can fail and can be retried (POST payment/credit are idempotent)
- Persist the payments/credits from the temporary table and perform the summary # updates (e.g. update budget, ledger, encumbrances, etc. - unavailable/available/encumbered/expended/etc.) - do this in a database transaction
NOTE: we might be able to move some of this logic to the BL module. The trade-off there is that the storage module would contain less logic, but would have a more complicated API.
Pros/Cons:
- Might be able to do some incremental processing instead of having to do it all at once
- Multiple single payments can be sent - avoids the massive request payload
- Individual payments/credits can be repeated if needed (the POST payment API is idempotent)
...