How to Run a Credit Card Billing Cycle¶
This guide walks a credit card programme through one full month on QPay's internal ledger — configuring the cycle, closing a statement, reading it, taking a payment, accruing interest on what was not paid, and reconciling the result.
Read Credit Card Invoicing first for the reasoning behind cutoffs, allocation order, and where finance charges land. This guide is the mechanics.
Time to complete: ~20 minutes
API: Ledger Accounts API — base path /api/issuer/ledger
Prerequisites¶
- You have joined the ledger and issued a credit card with an approved limit.
- Your client holds
manage-ledger-invoice(configure and run jobs),view-ledger-invoice(read statements) andregister-ledger-payment(record payments). See Ledger roles.
export TOKEN="eyJhbGci..."
export BASE="https://sandbox.qpay.quecto.com.br"
export LEDGER="$BASE/api/issuer/ledger"
export TENANT_ID="0192f3a1-7c4e-7000-8a1b-2c3d4e5f6071"
QPay already runs these jobs for you
Statement closing, finance-charge accrual and reconciliation run automatically on an hourly ticker. The endpoints in Steps 3, 6 and 7 exist so you can trigger a run out of band — right after changing configuration, or to pull a report on demand. They are safe to call repeatedly: a cycle that already closed is reported as skipped, not as an error.
Overview¶
flowchart TD
A[Configure the credit cycle] --> B[Read it back]
B --> C[Close the statements due today]
C --> D[List the card's invoices]
D --> E[Read one invoice with its lines]
E --> F[Record a payment]
F --> G[Accrue finance charges]
G --> H[Run reconciliation]
Step 1 — Configure the credit cycle¶
The billing cycle belongs to the card product. Every card issued against it inherits the configuration.
curl -s -X PUT $LEDGER/accounts/products/12/credit-cycle \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"closing_day": 10,
"due_day_offset": 10,
"minimum_payment_bps": 1500,
"interest_monthly_bps": 1200,
"late_fee_minor_units": "2000",
"grace_days": 3,
"timezone": "America/Sao_Paulo"
}' | jq .
| Field | Range | Meaning |
|---|---|---|
closing_day |
1–28 | Day of the month the statement closes |
due_day_offset |
1–60 | Days after closing that payment is due |
minimum_payment_bps |
0–10000 | Minimum payment in basis points of the total — 1500 is 15% |
interest_monthly_bps |
≥ 0 | Monthly interest rate in basis points — 1200 is 12% per month |
late_fee_minor_units |
— | Flat late fee as a decimal string of minor units |
grace_days |
0–60 | Days after the due date before interest starts |
timezone |
IANA name | The zone the cutoff is computed in |
closing_day stops at 28
Every month has a 28th. Allowing 29–31 would mean clamping in February, which would make cycle length vary month to month. This is enforced by the database, not just by validation.
Step 2 — Read the cycle back¶
The response echoes the configuration plus the two derived instants you will want to plan around:
Example response:
{
"product_id": 12,
"closing_day": 10,
"due_day_offset": 10,
"minimum_payment_bps": 1500,
"interest_monthly_bps": 1200,
"late_fee_minor_units": "2000",
"grace_days": 3,
"timezone": "America/Sao_Paulo",
"next_cutoff": "2026-08-10T03:00:00Z",
"next_due_date": "2026-08-20"
}
next_cutoff is a UTC instant — local midnight on the closing day in the product's timezone. next_due_date is a calendar date in that same timezone.
Step 3 — Close the statements falling due today¶
curl -s -X POST $LEDGER/accounts/invoices/closings \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"business_day": "2026-08-10"}' | jq .
Omit business_day to close for today.
Example response:
{
"business_day": "2026-08-10",
"closed": 1,
"skipped": 2,
"failed": 0,
"outcomes": [
{
"card_application_id": 9001,
"cycle_ref": "2026-08",
"invoice_id": "0192f4a0-1111-7000-8b2c-3d4e5f607182",
"total_amount": "134500",
"skipped": false
},
{
"card_application_id": 9002,
"cycle_ref": "2026-08",
"total_amount": "0",
"skipped": true,
"reason": "cycle total is not positive"
},
{
"card_application_id": 9003,
"cycle_ref": "2026-08",
"total_amount": "134500",
"skipped": true,
"reason": "cycle already closed"
}
]
}
Every credit application gets an entry in outcomes, whether or not a statement was produced. A skip always carries a reason and the amount, so the decision is auditable rather than silent.
reason |
Meaning |
|---|---|
cycle total is not positive |
Zero, or the cardholder is in credit — the balance carries into the next cycle |
cycle already closed |
This cycle was closed by an earlier run or by the hourly ticker |
One unreadable product never stops the rest of the run — it lands in failed with an error and the others still close.
A cutoff that has not arrived is refused
Passing a business_day whose cutoff is still in the future for that product returns an error, per product, compared as instants. An invoice a cardholder has already seen must never reopen at a different figure. Finance charges behave the opposite way — see Step 6.
A 422 mentioning the invoice-float backfill is an ops step
If a closing or payment call fails with a 422 telling you to run the invoice-float backfill, the environment predates invoicing and needs a one-time operator action. Contact support@quecto.com.br rather than retrying.
Step 4 — List a card's statements¶
Example response:
{
"invoices": [
{
"id": "0192f4a0-1111-7000-8b2c-3d4e5f607182",
"card_application_id": 9001,
"currency_numeric_code": 986,
"cycle_ref": "2026-08",
"total_amount": "134500",
"minimum_amount": "20175",
"outstanding_amount": "134500",
"cutoff_at": "2026-08-10T03:00:00Z",
"due_at": "2026-08-20",
"closed_at": "2026-08-10T03:04:11Z",
"receivable_account_id": "0192f4a0-2222-7000-8b2c-3d4e5f607183"
}
]
}
Newest first; empty for a card with no closed statement yet.
| Field | Fixed or live |
|---|---|
total_amount, minimum_amount, cutoff_at, due_at |
Fixed at closing — the document the cardholder received |
outstanding_amount |
Live — read from the ledger on every request, falls as the cardholder pays |
minimum_amount rounds up, so a cardholder paying the stated figure is never a centavo short.
Step 5 — Read one statement with its lines¶
export INVOICE_ID="0192f4a0-1111-7000-8b2c-3d4e5f607182"
curl -s $LEDGER/accounts/invoices/$INVOICE_ID \
-H "Authorization: Bearer $TOKEN" | jq .
Example response (abridged):
{
"id": "0192f4a0-1111-7000-8b2c-3d4e5f607182",
"cycle_ref": "2026-08",
"total_amount": "134500",
"minimum_amount": "20175",
"outstanding_amount": "134500",
"due_at": "2026-08-20",
"window_start_timestamp": 1751500800000000001,
"window_end_timestamp": 1754793600000000000,
"lines": [
{ "transfer_id": "0192f3d0-…", "posted_at": "2026-07-14T18:22:09Z", "amount": "89900", "credit": false, "code": 10, "mcc": 5411 },
{ "transfer_id": "0192f3e1-…", "posted_at": "2026-07-28T11:03:47Z", "amount": "60000", "credit": false, "code": 10, "mcc": 5812 },
{ "transfer_id": "0192f3f2-…", "posted_at": "2026-08-02T09:15:30Z", "amount": "15400", "credit": true, "code": 4, "mcc": 5812 }
]
}
Lines are oldest first and read back from the ledger — nothing is copied into a separate table. amount is always positive; credit tells you the direction.
code |
Line is |
|---|---|
10 |
A settled purchase |
4 |
A refund |
3 |
A reversal |
15 |
A finance charge (interest or late fee) |
Statement lines are on a posted-date basis: a purchase made before the cutoff but cleared by the network afterwards appears on the next statement. window_start_timestamp and window_end_timestamp are the exact ledger nanosecond bounds, so the window can be reproduced during an audit.
Step 6 — Record a payment¶
One call records the money and allocates it.
curl -s -X POST $LEDGER/accounts/tenants/$TENANT_ID/invoice-payments/986 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"card_id": 5821,
"amount": "150000",
"idempotency_key": "9a8b7c6d-5e4f-4a3b-8c1d-2e3f4a5b6c7d"
}' | jq .
Example response:
{
"amount": "150000",
"allocations": [
{
"invoice_id": "0192f4a0-1111-7000-8b2c-3d4e5f607182",
"cycle_ref": "2026-08",
"amount": "134500",
"outstanding_after": "0"
}
],
"excess": "15500",
"total_granted": "500000",
"available": "515500",
"used": "-15500"
}
What happened here:
- The payment was allocated oldest statement first. That order is fixed and not caller-controllable — it minimises interest and matches Brazilian market practice.
- Each allocation restored the same amount of
availablecredit. This is what gives the cardholder their limit back — settlement never does. - The R$ 155.00 left over became
excess, credited to the open cycle. The card is now in credit:usedis negative andavailableexceedstotal_granted. The cardholder can legitimately spend above their granted line, and the ledger will approve it.
| Failure | Status | Cause |
|---|---|---|
| Amount exceeds what actually arrived | 422 | Money cannot be allocated before it is received |
Same idempotency_key, different amount |
409 | Retry with the original amount, or use a new key |
Retrying with the same key and amount is safe — it reconstructs the original allocations rather than paying twice.
Step 7 — Accrue finance charges¶
Run this for statements past their due date plus grace period.
curl -s -X POST $LEDGER/accounts/invoices/finance-charges \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"business_day": "2026-08-28"}' | jq .
Example response:
{
"business_day": "2026-08-28",
"charged": 1,
"skipped": 2,
"failed": 0,
"total_charged": "4690",
"outcomes": [
{
"card_application_id": 9004,
"invoice_id": "0192f4b0-…",
"cycle_ref": "2026-08",
"outstanding": "134500",
"interest_amount": "2690",
"interest_days": 5,
"late_fee_amount": "2000",
"skipped": false
},
{
"card_application_id": 9001,
"invoice_id": "0192f4a0-…",
"cycle_ref": "2026-08",
"interest_amount": "0",
"late_fee_amount": "0",
"skipped": true,
"reason": "invoice is settled"
}
]
}
The interest figure follows from the Step 1 configuration: R$ 1,345.00 outstanding at 12% per month, spread over the fixed 30-day basis, is R$ 5.38 per overdue day. Five days is R$ 26.90, plus the R$ 20.00 late fee — R$ 46.90 charged in total.
Key behaviours to expect:
- Interest is computed on
outstanding— the live balance, nottotal_amount. A partial payment reduces every subsequent day's interest. interest_dayscounts overdue days charged by this run. The monthly rate is spread over a fixed 30-day basis, so a day costs the same in February as in March, and daily interest truncates down.- The late fee appears once per statement, ever — never once per overdue day.
- Both charges land on the open cycle, so they appear on the next statement. August's interest shows up on September's document.
Common skip reasons: invoice is settled, invoice is in credit, within grace, which ends …, already accrued through …, and N further overdue days remain when a catch-up run hit its per-run cap.
A future business day is clamped, not refused
Unlike statement closing, a business_day in the future is silently moved to today and the clamp is reported. Charging a day before it has happened would be an overcharge that can never be undone, because a given day can only ever be charged once.
This job runs against every configured product, not only those closing today — an overdue statement ages on its own clock.
Step 8 — Reconcile¶
A read-only report that re-derives the ledger's own invariants. It returns 200 even when it finds breaks — read total_breaks.
Example response (abridged):
{
"issuer_id": 42,
"applications": 3,
"total_breaks": 1,
"controls": [
{ "id": "C1", "detects": "…", "checked": 3, "complete": true, "breaks": [] },
{
"id": "C2",
"detects": "…",
"checked": 3,
"complete": true,
"breaks": [
{
"control": "C2",
"card_application_id": 9002,
"detail": "Limit_Used disagrees with the open cycle plus receivables",
"expected": "134500",
"actual": "119100",
"difference": "15400"
}
]
},
{ "id": "C3", "detects": "…", "checked": 3, "complete": false, "note": "…", "breaks": [] }
],
"suspense_account_policy": "…"
}
| Control | Checks |
|---|---|
| C1 | total_granted equals available + used |
| C2 | used equals the open cycle plus every statement receivable |
| C3 | Pending holds agree between the two shadow accounts |
| C4 | Each stored total_amount matches a recomputed window sum |
| C5 | Only a closing may debit and only an allocation may credit a receivable |
| C6 | The invoice payment float nets to zero per currency |
A break always reports both disagreeing figures, never just the difference, so the cause is diagnosable. Nothing is auto-corrected — a break is a signal for investigation, never a posting.
C3 and C6 return complete: false with a note explaining why: C3 cannot see holds the processor has not yet passed on, and C6 has no bank-statement feed to match against. They are still worth running; they just cannot claim completeness.
Related¶
- Credit Card Invoicing — the concepts and reasoning behind every step here
- Use the QPay Ledger — onboarding a cardholder and issuing the card first
- Ledger Accounts API reference — interactive endpoint documentation
- Ledger roles — which role each endpoint requires