Skip to content

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

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:

curl -s $LEDGER/accounts/products/12/credit-cycle \
  -H "Authorization: Bearer $TOKEN" | jq .

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

curl -s $LEDGER/accounts/cards/5821/invoices \
  -H "Authorization: Bearer $TOKEN" | jq .

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 available credit. 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: used is negative and available exceeds total_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, not total_amount. A partial payment reduces every subsequent day's interest.
  • interest_days counts 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.

curl -s $LEDGER/accounts/reconciliation/credit \
  -H "Authorization: Bearer $TOKEN" | jq .

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.