Skip to content
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ This repository uses an `x.y.z` release scheme.

## Unreleased

### Payments: several payments on one invoice, told apart by the receipt

- **Fixed: a balance recorded after a deposit that had already posted is no longer refused.** The last check before a payment is sent judged every payment already sent against the invoice, so a posted deposit blocked its own balance (and a failed one blocked the next receipt). IMS now identifies a payment by the receipt (payment row) it was recorded for. A *different* live receipt never blocks, a *repeat of the same receipt* is always refused, and a registration that cannot be shown to belong to a different receipt (imported before this change, or whose receipt was deleted) still blocks. One rule is shared by the registration decision, the final check, the over-settlement guard and the manual retry, so they cannot disagree. A deposit's payment in Xero is recognised by the reference IMS wrote on it, never by amount and date.
- **Changed: a paid WooCommerce order is imported with a payment row**, and the payment registered in Xero carries that receipt's identity. A hand-recorded receipt on the same order therefore registers normally.
- **New: `sales_orders.cancelled_at`** is stamped by the database when an order becomes cancelled. Receipts can now be recorded on a cancelled order; one dated after the cancellation is held (nothing sent, a warning says so) because Xero books that money as an overpayment, which IMS does not create yet. Paid before or at the cancellation, or with no known cancellation time, registers as before.
- **Unchanged on purpose:** a payment above what is outstanding is still refused (Xero refuses it too), so no excess is ever posted as a payment.

### Mintsoft: the despatch poll is scheduled, and a bundle create is not repeated after an uncertain send

- **The `mintsoft-dispatch-sync` job is now a registered scheduled job (every 15 minutes, on by default).** The installation guide always listed it as automatic, but nothing scheduled it, so a despatch in Mintsoft did not progress the IMS shipment unless someone added a crontab line by hand. **Upgrade step: open Settings > System > Scheduler and use Save & Apply once** so the new job reaches an existing crontab. The poll only reads from Mintsoft, does nothing while the Mintsoft plugin is off, and **never logs in**: a username/password login replaces the tenant API key for every integration that shares it, so with a fixed API key the poll runs, and in username/password mode it runs on the stored key and skips with a reason once that has expired. Use a fixed API key for an unattended poll. The read-sync liveness check now names the job and reports the poll as off when the job is switched off.
Expand Down
13 changes: 10 additions & 3 deletions app/actions/sales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3640,8 +3640,14 @@ export async function addPayment(input: {
},
})
if (!so) return { error: 'Order not found' }
if (so.status === 'CANCELLED' || so.refundStatus === 'FULL') {
return { error: `Cannot add payments to ${so.refundStatus === 'FULL' ? 'fully refunded' : so.status.toLowerCase()} orders` }
// A CANCELLED order can still receive a receipt: money really does arrive for orders that were
// cancelled (a bank transfer in flight, a customer who paid late), and an operator must be able to
// record it. What happens to it afterwards is decided by WHEN it was paid against the database-minted
// cancellation instant (lib/domain/accounting/payment-cancellation-timing.ts) - the ledger
// registration refuses a receipt dated after the cancellation, visibly, until the overpayment path
// exists. A fully refunded order still takes no further receipts.
if (so.refundStatus === 'FULL') {
return { error: 'Cannot add payments to fully refunded orders' }
}
if (input.currency !== so.currency) {
return { error: `Payment currency must match order currency (${so.currency})` }
Expand Down Expand Up @@ -3810,7 +3816,8 @@ export async function addPayment(input: {
}, STOCK_TX_OPTIONS)
if ('error' in txResult) return { success: false, error: txResult.error }

if (txResult.becamePaid) {
// A cancelled order does not get an invoice number because a late receipt covered it.
if (txResult.becamePaid && txResult.so.status !== 'CANCELLED') {
const trigger = await db.setting.findUnique({ where: { key: 'invoice_trigger' } })
if (trigger?.value === 'on_paid') {
await generateInvoiceNumber(input.orderId, { skipLog: true })
Expand Down
7 changes: 7 additions & 0 deletions app/actions/xero-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import type { LedgerSettlementProbe } from '@/lib/domain/accounting/ledger-settlement-evidence'
import { probeLedgerSettlement, settlementProbeKey } from '@/lib/connectors/accounting-settlement-probe'
import { lockFollowUpScope } from '@/lib/domain/accounting/followup-scope-lock'
import { NO_LIVE_RECEIPTS, loadLiveReceipts } from '@/lib/domain/accounting/payment-identity'
import { getAuthorizationUrl, disconnect, isConnected, getGrantedScopes, missingScopes } from '@/lib/connectors/xero'
import { syncChartOfAccounts, getXeroTaxRates } from '@/lib/connectors/xero'
import { syncXeroAccountBalanceSnapshots } from '@/lib/connectors/xero/account-balances'
Expand Down Expand Up @@ -855,6 +856,11 @@ export async function retryFailedXeroSync(
return planned
})
const siblingIds = siblingRows.map((row) => row.id)
// The receipts that still exist, read inside the scope lock: what tells a different payment's
// registration from a repeat of the candidate (payment-identity.ts).
const liveReceipts = scope.type === 'INVOICE_PAYMENT' && scope.referenceType === 'SalesOrder'
? await loadLiveReceipts(tx, scope.referenceId)
: NO_LIVE_RECEIPTS

// Planned PER CANDIDATE, against that candidate's own siblings. An earlier revision
// planned once per SCOPE from an arbitrary rows[0] and then refused every sibling id: with
Expand Down Expand Up @@ -882,6 +888,7 @@ export async function retryFailedXeroSync(
status: 'FAILED', // the candidate query selects only FAILED rows
},
siblings,
identity: { orderId: scope.referenceType === 'SalesOrder' ? scope.referenceId : null, liveReceipts },
// Never an empty ledger by default. Unreachable today — the loop above probes for
// exactly the types the guard reads a ledger for, both through isMoneyMovingSyncType
// — so no mutation can make this branch fire, and it is deliberate defence rather
Expand Down
10 changes: 7 additions & 3 deletions help-docs/xero-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -1282,7 +1282,7 @@ What a check does **not** do, by design:
| Xero is **reconnected** (to any organisation, including the same one) | Every earlier check lapses; record it again under the new connection |
| The list Xero returned changed between opening the dialog and recording | Nothing is recorded; reopen the dialog and look again |
| An unreadable payment carries **no id**, or IMS cannot tell which Xero connection answered | No check can be recorded; the warning says which |
| The unreadable payment belongs to **another receipt that already posted** on the same invoice | No check can help, and the warning says so: IMS's last check before sending refuses a second payment beside one it has already posted on the same invoice (a known limit for part payments) |
| The unreadable payment belongs to **a repeat of the same receipt** (an earlier entry for the same receipt that already posted) | No check can help, and the warning says so: a payment IMS has already sent for this receipt is never sent twice. A *different* receipt on the same invoice (the balance after a deposit) is not affected by it |

Checks are kept permanently and cannot be edited, deleted or truncated. Each one records the entry, the receipt,
the Xero payment ids, the Xero organisation and connection it was made under, who made it and when.
Expand Down Expand Up @@ -1509,9 +1509,13 @@ A payment recorded on a sales order (**Add Payment**) is registered against the

If any of those does not hold, the receipt is still recorded in the IMS, nothing is sent, and a warning naming the order appears in the activity log — the order then shows **NOT SENT TO LEDGER** until it is registered.

**Part payments.** Each receipt gets its own Xero payment, so a deposit followed by a balance settles the invoice in two steps without anyone touching Xero. The IMS keeps a running total instead of a one-at-a-time rule: the receipt that would take the total past the invoice is the one refused, and the warning names both figures. If a registration already on the invoice does not record its amount, the IMS cannot work out the room left and refuses rather than guess. **Known limit:** the last check made immediately before a payment is sent judges every payment IMS has already sent against the same invoice, and it currently refuses a payment beside one IMS has already *posted* there — so a balance recorded after a posted deposit is queued and then refused at that point, with the reason on the entry.
**Part payments.** Each receipt gets its own Xero payment, so a deposit followed by a balance settles the invoice in two steps without anyone touching Xero. What tells two payments apart is the **receipt**: IMS identifies a payment by the receipt (payment row) it was recorded for, not by the invoice, the amount or the date. A payment already sent for a *different* receipt on the same order never stops this one — not when it has posted, not when it failed, not when two receipts are the same size on the same day — while a **repeat of the same receipt** is always refused, because that is the only way the same money can reach Xero twice. IMS recognises a deposit's payment in Xero by the reference it wrote on it, never by amount and date alone: a payment somebody typed into Xero by hand with the same amount and date as this receipt still stops it, and a reference found on two Xero payments stops it too. The IMS keeps a running total instead of a one-at-a-time rule: the receipt that would take the total past the invoice is the one refused, and the warning names both figures. A failed payment for another receipt counts towards that total (it may have reached Xero) until the ledger shows it did not. If a registration already on the invoice does not record its amount, the IMS cannot work out the room left and refuses rather than guess. A failed deposit can be retried after the balance has posted, as long as Xero does not already hold it.

**Imported orders.** A paid WooCommerce order registers its payment automatically without creating a payment row in the IMS. That registration cannot be matched to any particular receipt, so while it is there the IMS will not register a hand-recorded one on the same order — recording "the" payment afterwards would pay the Xero invoice twice.
**Several payments for one order, and the cap.** Two receipts recorded for the same real payment (a double entry) are two receipts, so both are sent; the only stop is the total — IMS refuses a receipt that would take the order's receipts past its total, and Xero itself refuses a payment above what is still outstanding on the invoice (net of any credit notes). IMS does not create an *overpayment* in Xero: money that cannot be placed against the invoice stays recorded in IMS and is dealt with in Xero by hand. A repeat send of one receipt reuses the same idempotency key, so Xero returns the original payment instead of making a second.

**Receipts for cancelled orders.** A receipt can be recorded on a cancelled order. What happens next depends on **when it was paid compared with when the order was cancelled** (the cancellation time is stamped by the database at the moment the status changes). Paid *before or at* the cancellation, or when the cancellation time is not known, follows the ordinary credit-note path and registers as usual. Paid *after* the cancellation, the invoice is void or credited and Xero would refuse a payment against it (it books such money as an overpayment, which IMS does not create yet): the receipt stays recorded, **nothing is sent**, and the warning says so. Record it in Xero by hand as an overpayment on the customer.

**Imported orders.** A paid WooCommerce order is imported with its receipt: a payment row is created with the order, and the payment registered in Xero after the invoice posts carries that receipt's identity. A deposit or balance recorded by hand on the same order is therefore a *different* receipt and registers normally (within the invoice total). Only a registration that carries no receipt at all — one made before this was the case — cannot be told apart from a repeat of any receipt, and so still blocks a hand-recorded payment on that order.

**The fit check is applied again at the moment of posting**, not only when the receipt is recorded. Every payment bound for Xero — hand-recorded, re-registered after the invoice posts, or raised automatically by an imported order — is measured against the invoice one last time immediately before the call goes out, counting only payments that have actually posted. A payment that no longer fits is **not sent**: its sync entry is retired as cancelled and an error appears in the activity log naming the invoice, what is already registered against it, and the amount refused. Reconcile the invoice in Xero and register the balance there by hand if it is genuinely owed. If the order or its totals cannot be read at that moment, nothing is sent either and the entry retries — an unreadable order is never treated as permission to move money.

Expand Down
Loading
Loading