User guide

Payments — user and implementer guide

Looking for what it does rather than how to use it? Read the Payments overview .

What it is

Payments is the money-in half of Solidlio’s billing stack. It captures card and bank-debit payments through Stripe, records manual payments (cheque, e-Transfer, EFT, wire, cash), reconciles both against the invoice they belong to, and handles refunds, disputes and MSP payouts.

It covers two distinct money paths that share this service:

  • A customer pays a tenant’s invoice — an organization or its contact pays an Invoice raised in Solidlio. Money settles to the platform’s Stripe account (or, for MSP store sales, to the selling MSP’s connected account).
  • A tenant pays Solidlio — an account settles its own SaaS PlatformInvoice, via a saved card, a pre-authorized debit mandate, or an out-of-band transfer a platform admin records by hand.

Concepts

NounWhat it is
PaymentOne attempt to settle an Invoice. Carries amount, refundedAmount, currency, status, provider and method.
Invoice.paidAmountDerived, never incremented. Recomputed from payments and credit-note applications. One writer, one formula.
SavedPaymentMethodA Stripe payment method stored against an organization for reuse. Soft-deleted, never hard-deleted.
PlatformInvoiceSolidlio billing an account for its own subscription. A separate family from Invoice with its own reconciliation.
PlatformPaymentOne attempt to settle a PlatformInvoice. Amounts are integer cents, not decimal dollars.
MspStoreTransactionAn MSP store sale charged as a Stripe Connect destination charge, settling to the MSP’s connected account.
MspReserveAccountA withheld balance per MSP that absorbs chargebacks on store sales before they reach the platform.
FxRateA directed currency pair with an asOf timestamp. Refreshed daily; refused by the payout run once older than 48 hours.

Payment status

PENDINGPROCESSINGSUCCEEDEDPARTIALLY_REFUNDEDREFUNDED, with FAILED and CANCELLED as terminal alternatives.

PROCESSING matters: a pre-authorized debit takes days to clear. A payment in that state has been submitted but not received, and never counts toward an invoice’s paid total.

Providers and methods

provider is STRIPE or MANUAL. method is one of CREDIT_CARD, DEBIT_CARD, ACH, CHECK, CHEQUE, WIRE, E_TRANSFER, EFT, CASH, NET_TERMS, OTHER.

How paidAmount is calculated

paidAmount =
    SUM(payment.amount - payment.refundedAmount)
        over payments WHERE status IN (SUCCEEDED, PARTIALLY_REFUNDED, REFUNDED)
  + SUM(application.amount)
        over credit_note_applications WHERE invoiceId = ? AND reversedAt IS NULL

Two properties follow from this and are worth knowing:

  • A refund is subtracted by amount, not by status. Refunding $10 of a $100 payment leaves $90 against the invoice. A fully refunded payment contributes zero arithmetically rather than being filtered out.
  • It is absolute, not incremental, so running it twice for the same event produces the same answer. That is what makes redelivered Stripe webhooks safe.

Reconciliation recomputes status only when the invoice is SENT, VIEWED, PARTIAL, PAID or OVERDUE. A DRAFT invoice is never published by a payment, a VOID invoice is never re-issued by a refund, and an OVERDUE invoice that still has a balance is not reset to SENT by partial payment.


Roles and permissions

Probed across all six roles and three portals against the running service, not inferred from the middleware.

Actionplatform administratorMSP administratorMSP technicianorganization administratorpower userCUSTOMER
List / view payments, view stats
Record a manual payment
Raise a Stripe payment intent (POST /intent)
Refund a payment
Cancel a pending payment
Pay own invoice, manage own saved cards (portal)
Set up a card or PAD mandate for own account

The split that matters: reading payment history and recording money received are available to an organization admin; moving money back out is not. Refund, cancel and intent creation carry their own requireMinRole("MSP technician") inside the router, above the organization administrator mount.

Tenant boundaries

Every payment query is scoped through paymentScope, which resolves the caller’s visible accounts — their own, plus any account they primary-manage and any client they hold an active management link to. An MSP therefore sees and acts on its managed clients’ payments as well as its own; an unrelated account’s payment returns 404, not 403.

Merchant configuration deliberately does not widen. An MSP’s Stripe Connect account, payout settings and reserve policy stay on plain account scope — they are the MSP’s own settings, not a client’s data.

Platform admins span every account. Customer-portal callers are restricted to their own organization.


Walkthroughs

A customer pays an invoice online

Customer portal: Billing → Pay invoices, or Invoices → <invoice> → Pay … Now. Organization portal: Billing → Make a Payment.

Both entry points appear only when the organization has an outstanding balance.

  1. Select one or more unpaid invoices. The running total updates as you tick.
  2. Choose a saved card, or continue without one — you can enter a new card in the next step.
  3. Review the summary. Solidlio raises a Stripe PaymentIntent for the first invoice’s outstanding balance, in that invoice’s currency.
  4. Enter card details in the Stripe form and submit. This is the step that charges the card. Nothing before it moves money.
  5. On confirmation, Solidlio advances to the next selected invoice and repeats from step 3. Invoices are paid one at a time because a PaymentIntent is confirmed against a single card form.
  6. When the last invoice confirms, the wizard reports success.

Settlement is recorded by Stripe’s payment_intent.succeeded webhook, not by the browser. If you close the tab after confirming, the payment still lands.

Staff records a payment received out of band

  1. Identify the invoice and the amount actually received.
  2. Record it with the matching method — CHEQUE, E_TRANSFER, EFT, WIRE, CASH, NET_TERMS or OTHER — plus a reference (cheque number, transfer confirmation) and optional notes.
  3. Solidlio checks the amount against the invoice’s outstanding balance and rejects anything larger. It does not silently reduce the figure, because that would destroy the record of what was actually received. Split the allocation across invoices instead.
  4. The payment is created SUCCEEDED with provider = MANUAL, reconciliation runs, and a QuickBooks sync is queued if the integration is connected.

Refunding a payment

Three surfaces, and they do not offer the same thing. Check you are on the right one before you start.

MSP portal — full refund only: Payments → <payment> → Refund.

  1. Open the payment. The Refund button appears only for a SUCCEEDED payment whose provider is STRIPE; a manually recorded receipt has no Refund button here, and neither does an already partially refunded payment.
  2. Confirm the dialog. It refunds the full payment amount — there is no amount field on this screen.
  3. The invoice is re-reconciled and a refund confirmation email goes to the organization’s primary contact.

Platform finance console — full or partial: Platform → Finance → Payments → <payment> → Refund. This modal has a full/partial toggle and an amount input capped at the payment amount, plus a required reason.

A PARTIALLY_REFUNDED payment stays refundable up to amount − refundedAmount, so a payment can be partially refunded more than once.

Regardless of surface:

  • The amount is capped at amount − refundedAmount, so successive partial refunds can never together exceed what was captured.
  • Solidlio claims the payment atomically before calling Stripe, so two simultaneous refund requests cannot both succeed — the loser gets 409.
  • A retry of the same refund collapses to one refund at Stripe; a genuine second refund is not swallowed as a replay.

A tenant sets up recurring payment for its own subscription

Organization or MSP portal: Billing → Subscription & Invoices (/org/billing/invoices, /msp/billing/invoices).

  1. Choose card or pre-authorized debit.
  2. Card: enter details in the Stripe form. PAD: complete the mandate — Solidlio selects the rail from your account currency (CAD → acss_debit, USD → us_bank_account, EUR → sepa_debit), so the mandate always matches what will later be charged.
  3. On success, Stripe’s setup_intent.succeeded webhook stores the method as the account default. The setup screen itself never writes it.
  4. The nightly billing run charges that method off-session when an invoice falls due.

A tenant pays a subscription invoice by hand

Platform admin: Platform → Billing → Tenant invoices → Record payment.

For accounts on EFT or e-Transfer terms, a platform admin records the receipt against the PlatformInvoice. Recording is deduplicated on (invoiceId, reference) and clamped at the invoice total, and settling the invoice in full advances the subscription period and reactivates it.


Configuration

SettingWhat it doesIf unset
Account.defaultCurrencyCurrency for the account’s own invoices and the bank-debit rail chosen at mandate setup.CAD.
Invoice.currencyCurrency each payment against that invoice is charged and recorded in.CAD.

The service refuses to start on an invalid configuration in every environment, rather than substituting defaults — a payment service that cannot state what it is configured with must not accept payments.

Scheduled jobs

JobSchedule (UTC)What it doesEnabled by
fx-rate-sync05:30Refreshes the FxRate table from the provider.on unless disabled

The order is deliberate: payouts run after both the reserve release and the FX refresh, so a sweep converts at a rate fetched the same morning.

Every money-moving job is opt-in. A fresh deployment does not start charging, dunning or paying out until an operator turns it on.

All four accept a dryRun flag. The three that delegate to a service over HTTP — the billing run, the reserve run and the payout sweep — propagate the flag, run the identical decision loop read-only, and report the counts they would have produced. Each then checks that the service echoed dryRun back, and treats a missing echo as a hard failure: a dry run that silently became a real run would have charged cards and moved money, and must never be reported as clean. fx-rate-sync has no remote leg and simply returns without writing.


Plan tiers

Payments is included in every plan, including Free. There is no feature flag and no usage limit on payments, payment methods, refunds or reconciliation anywhere in the tier catalogue.


Security properties

These are verified behaviours, not intentions.

The raw body genuinely reaches the verifier. Raw-body middleware is mounted on the webhook path ahead of JSON parsing, and the verifier additionally asserts the body is a Buffer or string and fails loudly if it is not — a re-serialized body can never match a Stripe signature.

Keys incorporate the request shape, because Stripe rejects a reused key whose parameters differ rather than collapsing it.

Concurrency is claimed atomically. Refund and cancel both flip the payment’s state conditionally before calling Stripe and return 409 to the loser; the refund claim also pins refundedAmount so two concurrent partial refunds cannot both proceed. Invoice reconciliation takes a row lock, so overlapping webhook deliveries cannot persist a stale total.

Currency integrity. All money is Decimal or integer cents; there is no floating-point arithmetic on currency anywhere in the service. Payments are charged and recorded in the invoice’s own currency.

FX rates expire. The payout run refuses a provider rate older than 48 hours and skips the account, leaving the sales unclaimed for the next run, rather than converting at a stale rate. Manual override rates never expire.


Troubleshooting

Error strings below are the exact text the service returns.

MessageCauseWhat to do
Payment amount 500.00 exceeds the outstanding balance of 100.00 on this invoiceA manual payment larger than what the invoice still owes.Split the receipt across the invoices it actually covers.
Invoice has no outstanding balanceFully covered by payments and/or credit notes already.Check payment history; the money may already be recorded.
Invoice is already fully paid / Invoice is already paidInvoice status is PAID.No action — confirm against payment history.
No amount due on this invoicetotal − paidAmount is zero or negative when raising an intent.Reconcile the invoice; a credit note may have covered it.
Refund amount cannot exceed the 20.00 remaining on this paymentRefund larger than amount − refundedAmount.Refund the remaining balance, or find the other payment.
Payment has already been fully refundedNothing left to refund.Check refundedAmount on the payment.
Can only refund successful paymentsStatus is PENDING, PROCESSING, FAILED or CANCELLED.A PROCESSING bank debit has not cleared — wait for settlement.
Payment is already being refunded or is not refundable (409)Another refund request claimed the payment first.Reload; the first refund likely succeeded.
Can only cancel pending paymentsOnly PENDING payments can be cancelled.Refund it instead if it has settled.
Webhook raw body unavailable (500)Raw-body middleware misconfigured; a parsed body can never verify.Deployment/proxy issue — check the webhook mount.
Organization context required (400)A self-serve route reached without an organization in the auth context.Sign in again; the session is missing its organization claim.
Organization not accessible (403)The target organization is outside the caller’s visible accounts.Expected for cross-tenant access.
Invalid payment methodThe saved method does not belong to the paying organization or is inactive.Re-add the card.
The selling MSP has not connected a Stripe account to receive card paymentsStore checkout against an MSP with no Connect account.The MSP must finish Connect onboarding. The order stays unpaid.
Store order already has a payment intent or is not awaiting payment (409)Second charge attempt on the same order.Expected — the guard preventing a double charge.

“The payment says PROCESSING and the invoice is still unpaid.” Correct for a pre-authorized debit. It settles in days, and only payment_intent.succeeded marks the invoice paid.

“The customer confirmed a card but the invoice did not update.” Settlement is webhook-driven.


Limits and known behaviour

Stated plainly, because each one is something the product does not do today.

  • Disputes on a client invoice are not tracked. Stripe dispute events are reconciled only against subscription payments and MSP store sales. A chargeback on an organization invoice does not reverse it — the invoice stays PAID and the reversal must be handled manually. Store chargebacks are absorbed against the MSP reserve.
  • Overpayment is rejected, not converted to credit. Solidlio does not automatically raise a client credit for an excess receipt.
  • The invoice payment wizard pays one invoice at a time. Selecting several works, but each is confirmed separately; there is no single combined charge.
  • The stats-and-filters view lives at /msp/payments. Its access gate no longer excludes organization admins — it was checking isStaff, which is MSP-only, and so contradicted a backend that admits organization administrator; it now matches the API, and the refund control within it is separately restricted to MSP staff. Mounting that view on the organization portal is the remaining step and has not been done.
  • No partial payment from the portal. The self-serve flow always charges the full outstanding balance; partial receipts are recorded by staff.
  • The MSP portal’s refund button is full-refund-only and Stripe-only. It offers no amount field, and it does not appear for a manually recorded payment or for one already partially refunded. Partial and repeat-partial refunds are available in the platform finance console and over the API, but not yet on the MSP screen.
  • Refunds go back to the original payment method only. There is no alternate-destination refund.
  • Non-CAD is supported but lightly exercised. Payments are charged and recorded in the invoice’s currency, and the payout run converts through the FX table. Most production activity is CAD.

Verification

  • Every UI path named in §4 was confirmed to exist on disk.
  • Every error string in §9 was extracted from the service source.

Questions this guide did not answer?

Ask us. You will get a reply from someone who uses the product every day.

Book a demo Contact us

A 30-minute walkthrough against your own workflow. No slides.