# Stripe Invoice Auto-Paying — Design Document

> Related: One pager: automation stripe invoice (Phase 2)
> Service: `billing/`
> Updated: 2026-08-04

---

## Summary

**Auto-paying** = find all `generated` Stripe invoices + call Stripe Pay (confirm + send), done automatically by the system.

To avoid spamming customers with many $0.30 / $0.50 small invoices, we introduce **accumulation**: invoices under **$1** are not pushed individually, but held in a carryover pool; once the accumulated amount crosses $1, they are merged and pushed.

After pushing to Stripe, **single-month normal invoices auto-charge** (`auto_advance=true`), while **multi-month accumulated invoices do not auto-charge** (`auto_advance=false`) — Finance handles those separately.

Auto-paying has **only one trigger source**: Finance clicks the **"Run Auto Payment Now"** button in the UI, inputs the target month (e.g. `2026-06`), and the API is called. Before clicking, Finance can manually mark invoices as `suspend` if they should not be pushed.

The paying task is DB-persisted: the API creates a row in `stripe_invoice_paying`; the worker scans every minute, picks up pending tasks, runs evaluation, marks `completed`. **Only one pending paying per month** — must finish before another can be written.

---

## Design Principles

| Decision | Reason |
|----------|--------|
| **Only Finance button triggers; no internal hooks** | Cross-system auto hooks create noisy 409 retries; the Finance button is sufficient |
| **DB-persisted paying task, no events / no message bus** | Simple, observable, debuggable; no new middleware dependency |
| **One pending paying per month** | Prevents duplicate triggers; worker reads latest data when running |
| **API rejects (409), no silent dedup** | Caller needs to know "a paying is already running" |
| **`POST /paying` takes `month: "yyyy-mm"` directly** | Clear intent: pass the month you want to run; no enums, no arbitrary ranges |
| **`GET /paying` is a single dashboard endpoint** | Finance UI home needs only one call |
| **Finance click = trigger; no middle confirm step** | The click IS the decision; one more step is over-engineering |
| Worker polls every 1 minute | Simple, predictable |
| < $1 not pushed individually, accumulated | Small invoices are noise; cross-month accumulation is a sensible cadence |
| Single-month pushes auto-charge; accumulated does not | Accumulated includes prior <$1 amounts the customer doesn't know about — auto-charging can trigger disputes |
| Carryover cap = 12 months | Prevents runaway accumulation; expired → audit + manual handling |
| All alerts via Prometheus AlertManager | Sentry is being decommissioned |

---

## Business Rules

### Old Mode vs New Mode

| | Old Mode (Manual) | New Mode (Auto) |
|--|-------------------|-----------------|
| Who pushes to Stripe | Finance manually clicks button in backend each month | Finance clicks button to trigger API; worker auto-calls Stripe Pay |
| How < $1 amounts are handled | Finance manually merges | System automatically accumulates and merges |
| Who marks "don't push" | Finance manually marks `suspend` | Finance still marks `suspend` (capability preserved) |
| Duplicate-push risk | Manual clicking may repeat | DB unique constraint prevents duplicates |

### Trigger and Finance Operations

**Only trigger source**: Finance clicks "Run Auto Payment Now" in UI → inputs month → calls `POST /paying`.

The request body must include `month` (`yyyy-mm` format, e.g. `"2026-06"`), meaning "run paying for June 2026".

API behavior:
- Same month already has `pending` / `running` paying → return **409 Conflict** (a paying is already running, wait for it to complete)
- Same month only has `completed` / `failed` → allow creating a new paying

**Additional operations Finance can do before triggering** (use existing API, not added by this design):

| Operation | When | Which API |
|-----------|------|-----------|
| Mark `suspend` | Don't want to push an invoice (complaint waiver, offline payment, refund offset) | Existing `SuspendInvoice` |

Note: upstream `CreateInvoiceWorker` already auto-suspends permanent free users, internal accounts, and wire-transfer users during generation (out of this design's scope).

### Scan Range + Month Parsing

When the worker runs paying, it **only processes invoices satisfying ALL of**:

| Condition | Description |
|----------|-------------|
| `status = GENERATED` | Not yet processed |
| `payment_type = STRIPE` | Stripe channel only (Aliyun / GCP / AWS MP do **NOT** go through this path) |
| `payable = true` | The (tenant, billing_config) has a usable Stripe payment method (valid card) |
| `tenant.kind != internal` | Exclude internal test accounts (defensive, even if none currently exist) |
| `invoice_date` is within the month range | Range = 1st of that month 00:00 UTC to 1st of next month 00:00 UTC (accepts any `yyyy-mm`, can backfill) |

Invoices already in `SUSPENDED` / `BELOW_THRESHOLD_CARRY` / `EXPIRED_CARRYOVER` / `SENT` are not re-selected.

### $1 Accumulation

| Scenario | Example | Behavior |
|----------|---------|----------|
| Single ≥ $1 | March $3.00 | **Directly call Stripe Pay** (confirm + send) |
| Single < $1 | Feb $0.50 | **Don't push**, hold in carryover pool |
| Accumulated ≥ $1 | Feb $0.50 + March $3.00 = $3.50 | **Merge and call Stripe Pay** |
| Accumulated still < $1 | Feb $0.50 + March $0.40 = $0.90 | **Keep holding**, wait for next paying trigger |

Invoices held in the pool have local status `below_threshold_carry`; customers don't see them.

### 12-Month Cap

Each evaluation also checks: whether the carryover's `first_carry_month` is more than 12 months ago.

- If expired and still < $1:
  - Those invoices have local status → `expired_carryover`, kept for audit
  - Send alert; Finance decides manually (write off / force push / contact customer)

### Charge Behavior (by push type)

When the worker calls Stripe Pay, the push type decides `auto_advance`:

| Push Type | Auto-charge? | Stripe `auto_advance` |
|-----------|--------------|----------------------|
| Single-month normal push (1 invoice, same month) | **Yes** | `true` |
| Multi-month accumulated push (multiple or multi-month) | **No** | `false` |

Non-auto-charged invoices stay at `open` after pushing to Stripe; Finance handles separate charging (out of this design's scope).

---

## End-to-End Flow

```
Finance                                          paying table                                     worker
──────                                          ────────────                                      ──────
   1. Optional: mark `suspend` for invoices that shouldn't be pushed
      (uses existing SuspendInvoice API)

   2. Click "Run Auto Payment Now" and input month
      (e.g. 2026-06) ─────────────► ┌─────────────────────┐
                                  │   status=pending     │  ← POST /paying
                                  │   month=2026-06       │     create paying (409 if same-month already active)
                                  │                       │
                                  └──────────┬──────────┘
                                             │  1-min polling
                                             ▼
                                  ┌─────────────────────┐
                                  │   status=running     │
                                  └──────────┬──────────┘
                                             │
                                             │  filter invoices per §Scan Range
                                             │  group by (tenant, bc)
                                             │
                          ┌──────────────────┬──┴────────┬─────────────────────────┐
                          ▼                  ▼           ▼
                   total ≥ $1          total < $1   > 12 months
                          │                  │           │
                          ▼                  ▼           ▼
                  Stripe Pay              Update        Local →
                  (confirm+send)         carryover     EXPIRED_CARRYOVER
                  Local → SENT           pool
                  Write stripe_          Local →
                  invoice_id,           BELOW_THRESHOLD_
                  charge_mode            CARRY
                          │                  │           │
                          └────────┬─────────┴───────────┘
                                   ▼
                                  ┌─────────────────────┐
                                  │   status=completed   │
                                  │   result={confirmed, │
                                  │           carryover_  │
                                  │           updated,    │
                                  │           expired}    │
                                  └─────────────────────┘
```

Key: Finance click = start auto-paying; worker runs the paying by directly calling Stripe Pay (confirm + send) with no intermediate confirm step.

---

## Data Model

Two new tables, two new status enum values, and two new fields on the `invoices` table.

### `stripe_invoice_paying` (paying task table)

| Field | Type | Description |
|-------|------|-------------|
| id | uint PK | |
| month | varchar(7) | Target month `'yyyy-mm'` (e.g. `'2026-06'`), passed in directly by the API |
| status | enum | `pending` / `running` / `completed` / `failed` |
| created_by | string | Finance user_id |
| created_at | timestamp | |
| picked_up_at | timestamp | When the worker started executing |
| completed_at | timestamp | |
| result | json | Evaluation result (confirmed / carryover_updated / expired) |
| error_message | text | Failure reason |
| active_flag | tinyint generated | `CASE WHEN status IN ('pending','running') THEN 1 ELSE NULL END` STORED |

Unique constraint (at most one active paying per month):

```sql
UNIQUE KEY uk_active_paying (month, active_flag)
```

Index (for worker polling):

```sql
INDEX idx_pending (status, created_at)
```

### `stripe_invoice_push_carryover` (carryover pool)

| Field | Type | Description |
|-------|------|-------------|
| tenant_id / billing_config_id | uint | Composite unique |
| amount | decimal | Current accumulated amount |
| source_invoice_ids | json | `[{invoice_id, amount, month}, ...]` |
| first_carry_month | date | Earliest month in the pool (used for 12-month expiry check) |
| version | int | Optimistic lock |

### New fields on `invoices` table

| Field | Type | Description |
|-------|------|-------------|
| stripe_invoice_id | string | Stripe invoice ID populated after push |
| charge_mode | enum | `auto` / `manual`, decided by push type at push time |

### New `InvoiceStatus` constants

```go
InvoiceStatus_BELOW_THRESHOLD_CARRY = "below_threshold_carry"
InvoiceStatus_EXPIRED_CARRYOVER    = "expired_carryover"
```

`SUSPENDED` / `SENT` / `GENERATED` already exist and are reused.

---

## API

A total of **3** endpoints, all under `/api/v1/billing/paying`.

| Method | Path | Purpose | Who can call |
|--------|------|---------|--------------|
| POST | `/paying` | Create a paying task | Finance |
| GET | `/paying` | Finance dashboard (list + carryover summary) | Finance |
| GET | `/paying/{id}` | Single paying detail | Finance |

Swagger annotations must be added to each handler so `make swagger` produces the correct OpenAPI for frontend.

### `POST /paying` — Create paying task

**Request**

```json
{
  "month": "2026-06"
}
```

| Field | Required | Format | Description |
|-------|----------|--------|-------------|
| month | yes | `yyyy-mm` | Target month, e.g. `"2026-06"` |

**Success response (201 Created)**

```json
{
  "paying_id": 42,
  "month": "2026-06",
  "status": "pending",
  "created_at": "2026-06-03T02:00:00Z"
}
```

**Conflict response (409 Conflict)** — same month already has pending / running

```json
{
  "error": "paying_already_active",
  "message": "There is already an active paying for month 2026-06. Wait for it to complete before creating a new one.",
  "existing_paying": {
    "paying_id": 41,
    "month": "2026-06",
    "status": "running",
    "created_at": "2026-06-03T01:50:00Z",
    "picked_up_at": "2026-06-03T01:51:00Z"
  }
}
```

Finance UI handling: show "paying #41 for 2026-06 is already running" and direct the user to the dashboard. **Do not** retry or error.

`force=true` is Ops-only: emergency case to force-create a paying, bypassing the same-month uniqueness.

### `GET /paying` — Finance dashboard

A single GET to retrieve everything Finance UI needs.

**Query parameters**

| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| paying_limit | no | 20 | Number of payings to return (max 100) |

**Response**

```json
{
  "payings": [
    {
      "paying_id": 42,
      "month": "2026-06",
      "status": "completed",
      "created_by": "user:12345",
      "created_at": "2026-06-03T02:00:00Z",
      "completed_at": "2026-06-03T02:00:15Z",
      "duration_seconds": 15
    },
    {
      "paying_id": 41,
      "month": "2026-05",
      "status": "running",
      "created_by": "user:67890",
      "created_at": "2026-06-03T01:50:00Z",
      "picked_up_at": "2026-06-03T01:51:00Z"
    }
  ],
  "carryover_summary": {
    "total_count": 47,
    "total_amount": "12.30",
    "oldest_first_carry_month": "2025-11"
  }
}
```

### `GET /paying/{id}` — Single paying detail

**Response**

```json
{
  "paying": {
    "paying_id": 42,
    "month": "2026-06",
    "status": "completed",
    "created_by": "user:12345",
    "created_at": "2026-06-03T02:00:00Z",
    "picked_up_at": "2026-06-03T02:00:01Z",
    "completed_at": "2026-06-03T02:00:15Z",
    "duration_seconds": 15,
    "result": {
      "confirmed": [
        {
          "tenant_id": 100,
          "billing_config_id": 200,
          "merged_invoice_ids": [10001],
          "amount": "3.00",
          "push_type": "single_month",
          "charge_mode": "auto",
          "stripe_invoice_id": "in_1ABC..."
        },
        {
          "tenant_id": 105,
          "billing_config_id": 205,
          "merged_invoice_ids": [9987, 10002],
          "amount": "3.50",
          "push_type": "accumulated",
          "charge_mode": "manual",
          "stripe_invoice_id": "in_1DEF..."
        }
      ],
      "carryover_updated": [
        { "tenant_id": 110, "billing_config_id": 210, "new_total": "0.80" }
      ],
      "expired": [],
      "skipped": []
    }
  }
}
```

When `status="failed"`, `result` is null and `error_message` is populated.

---

## Worker

### `StripeInvoicePayingWorker` (paying task executor)

**Schedule**: polls every 1 minute (gocron interval = `"1m"`)

**Flow**

```
Every minute:

1. Atomically claim a paying (multi-pod safe):

   UPDATE stripe_invoice_paying
   SET status='running', picked_up_at=NOW()
   WHERE status='pending'
   ORDER BY created_at ASC LIMIT 1;

   If 0 rows affected → no work this round

2. Parse paying.month → compute date range [start, end)
   - start = 1st of that month 00:00 UTC
   - end   = 1st of next month 00:00 UTC

3. Scan invoices matching §Scan Range conditions:
   - status = 'generated'
   - payment_type = 'stripe'
   - payable = true
   - tenant.kind != 'internal'
   - invoice_date ∈ [start, end)

4. Group by (tenant, billing_config)

5. For each group:
   a. Merged = all invoices in range for this (tenant, bc) + this (tenant, bc)'s carryover pool
   b. Sum → total
   c. total ≥ $1:
      - Call PushInvoice (Stripe Pay wrapper: confirm + send)
        - merged_invoice_ids length=1 AND same month → charge_mode=auto
        - otherwise → charge_mode=manual
      - Local invoice status → SENT
      - Write stripe_invoice_id, charge_mode
      - Clear consumed source_invoice_ids from the carryover pool
      - Append to result.confirmed
   d. total < $1 and not over 12 months:
      - Update the carryover pool
      - Local invoice status → BELOW_THRESHOLD_CARRY
      - Append to result.carryover_updated
   e. Over 12 months:
      - Local invoice status → EXPIRED_CARRYOVER
      - Append to result.expired

6. UPDATE paying SET status='completed', completed_at=NOW(), result=?

   Any step failure → status='failed', error_message=?; **do not retry** (Finance recreates the paying manually)
```

**Idempotency**:
- DB unique constraint `(month, active_flag)` prevents duplicate pending per month
- Worker uses `UPDATE ... WHERE status='pending'` atomic claim
- Local invoice status prevents re-evaluation (already SENT / BELOW_THRESHOLD_CARRY / SUSPENDED are not re-selected)

---

## `PushInvoice` Method (Stripe Pay wrapper)

Add to `handler/paymenthandler/stripe.go` (based on existing `StripePaymentHandlerImpl`):

```go
type InvoiceChargeMode string

const (
    ChargeModeAuto   InvoiceChargeMode = "auto"
    ChargeModeManual InvoiceChargeMode = "manual"
)

func (s *StripePaymentHandlerImpl) PushInvoice(
    paymentInfo    *forms.PaymentMethod,
    billingCompany *forms.BillingCompany,
    invoices       []forms.Invoice,
    mergeMetadata  map[string]string,
    chargeMode     InvoiceChargeMode,
) (*forms.Invoice, error)
```

**This wraps Stripe Pay (confirm + send)**:

1. `client.Invoices.New(...)` — create draft invoice
2. For each invoice, iterate categories and call `client.InvoiceItems.New(...)` for each line item
3. `client.Invoices.FinalizeInvoice(in.ID, &stripe.InvoiceFinalizeParams{AutoAdvance: chargeMode == auto})` — finalize + send (this IS the send)
4. Write metadata (`merged_from_invoice_ids`, `merged_months`, `charge_mode`)
5. Local invoice status → `SENT`; write `stripe_invoice_id` and `charge_mode`

**Callers**:

| Caller | chargeMode decision |
|--------|---------------------|
| Worker for single-month push (merged_invoice_ids length=1 and same month) | `auto` (Stripe auto-charges) |
| Worker for accumulated push (merged_invoice_ids length>1 or multi-month) | `manual` (no auto-charge) |

---

## Configuration

```go
StripeInvoicePayingWorkerInterval = "stripe-invoice-paying-worker-interval"  // default "1m"
StripePushMinAmountUSD            = "stripe-push-min-amount-usd"             // default "1.00"
StripePushMaxCarryoverMonths      = "stripe-push-max-carryover-months"       // default 12
```

---

## Monitoring and Alerts

### Prometheus Metrics

| Metric | Type | Description |
|--------|------|-------------|
| `paying_total` | counter | `{result="created\|rejected\|picked_up\|completed\|failed"}` |
| `paying_pending_age_seconds` | gauge | Age of the oldest pending paying |
| `paying_duration_seconds` | histogram | Evaluation duration |
| `push_total` | counter | `{result, push_type, charge_mode}` |
| `push_amount` | histogram | Merged push amount per push |
| `carryover_count` | gauge | Total carryover pool entries |
| `carryover_total_amount` | gauge | Total carryover amount |
| `carryover_oldest_month_age_seconds` | gauge | Age of the oldest carryover's first month |
| `unpaid_manual_count` | gauge | Invoices pushed but not yet paid (`charge_mode=manual` and Stripe unpaid) |
| `expired_total` | counter | Carryover expirations (> 12 months) |

> All metrics use namespace `dbaas_billing_stripe_invoice_*`.

### AlertManager Rules

Add the rule file under `runbooks/rules/billing/stripe-invoice-push.yaml`, all routed to `oncall_team=billing`. **Full YAML lives in the runbooks repo**; here we list only the semantic trigger:

| Alert | Trigger | Severity |
|-------|---------|----------|
| `PayingStuckRunning` | paying status=running over 30 minutes | critical |
| `PayingPendingTooLong` | pending paying not picked up within 5 minutes | warning |
| `PayingNoActivity` | last successful paying completion > 35 days ago (backstop: did Finance forget to click?) | warning |
| `CarryoverPoolTooLarge` | carryover pool > 200 customers | warning |
| `CarryoverExpired` | any carryover > 12 months old | warning |
| `UnpaidManualBacklog` | unpaid manual > 30 entries AND oldest > 14 days | warning |
| `AutoChargeFailureRateHigh` | single-month auto-charge failure rate > 5% | critical |

### Audit

Reuse the existing `auditlog` SDK with three event types:

- `StripeInvoicePayingCreated` — `/paying` API creates a paying (includes created_by Finance user_id)
- `StripeInvoicePayingCompleted` — worker completes a paying
- `StripeInvoicePushed` — worker actually pushes to Stripe (includes charge_mode, stripe_invoice_id)

---

## Rollout Plan

### Gradual Rollout Strategy

**Three phases**:

| Phase | Content | Time | Monitoring Focus |
|-------|---------|------|------------------|
| 1 | `/paying` API live; UI dashboard + "Run Auto Payment Now" button (with `yyyy-mm` input) | T+0~T+3 | End-to-end path: button click → paying created → worker runs → dashboard updates |
| 2 | Actually run a month-end cycle (Finance manually picks a past month) | T+3~T+7 | Cross-month carryover merging correctness; `force=true` availability in emergencies |
| 3 | Open to more Finance users + full rollout | T+7~T+14 | Carryover pool converges; `unpaid_manual` decreases |

### Risks and Mitigations

| Risk | Mitigation |
|------|-----------|
| Accumulated push mistakenly set to `auto` | Hard code constraint: `merged_invoice_ids` length=1 AND same month → `charge_mode=auto`, otherwise `manual` |
| paying stuck in running | `PayingStuckRunning` alert (30-minute threshold) |
| paying stuck in pending (worker not picking up) | `PayingPendingTooLong` alert (5-minute threshold) |
| Finance forgets to click button (whole month missed) | `PayingNoActivity` backstop alert (35-day threshold) |
| Duplicate push | Stripe idempotency-key + local status defense |

### Rollback

- Revert the paying API + worker deploy (no longer gated by a config flag)
- Existing pending / running payings **continue executing**
- Already-pushed local invoices are not rolled back (Stripe-side settled; rolling back has financial implications)
- Carryover pool entries are kept; they resume accumulating on the next enable
- `charge_mode=auto` already-charged entries **can only be voided on Stripe side**; requires Finance manual action

---

## FAQ

**Why is this feature needed?**

Old mode had Finance manually log into the backend each month and click buttons to push to Stripe, and small invoices were pushed individually, creating chaos on Stripe and annoyance for customers. After this change: Finance does not have to remember to click monthly (just trigger when needed), customers stop receiving $0.30 / $0.50 invoices, rules are unified (one run per month, no duplicates), and amount actions are controllable (Finance click = approval).

**What if a paying gets stuck?**

`PayingStuckRunning` (30 minutes still running) fires. Ops can either manually `UPDATE ... SET status='pending'` to retry, or use `force=true` to create a new paying over it (Ops role only).

**Why the internal-account filter?**

Defensive. Even though upstream `CreateInvoiceWorker` already suspends internal users during generation (so internal `generated` invoices should never appear), the worker filters again here as double protection.

**What if I clicked the button and realize it pushed the wrong thing?**

This system does not provide rollback. Manually void on Stripe, then on DB revert the invoice's status back to `GENERATED`, clearing `stripe_invoice_id` and `charge_mode`.

---

## Open Questions

- Should accumulated invoices be tagged with `description: "Carried from 2025-11"` on Stripe side?
- The charging flow for `charge_mode=manual` (out of this design's scope)

---

## Acceptance Criteria

Before entering development phase, all must be satisfied:

- [ ] DB migration scripts are explicit: `stripe_invoice_paying` table + fields + unique constraint + index
- [ ] Three API endpoints have complete request/response schemas (success, 409, list, single — 4 response shapes)
- [ ] Worker pseudocode can be line-by-line translated to Go (no design rework needed)
- [ ] `PushInvoice` method signature + 5-step call flow is implementable directly
- [ ] Filter conditions (status / payment_type / payable / kind / invoice_date) are implementable directly in SQL / Go
- [ ] Monitoring metrics + alert names are fully named (no ad-hoc naming during coding)
- [ ] Feature flag defaults to false; 3-phase rollout plan is concrete
- [ ] FAQ covers common operational questions from Finance

---

## Implementation Steps

AI-collaborative dev checklist (each step fits in a single day; time is not the bottleneck — design review is):

**Foundation**

- [ ] DB migration: `stripe_invoice_paying` table (with `active_flag` unique constraint), `stripe_invoice_push_carryover` table, 2 new status constants, `invoices` table additions (`stripe_invoice_id` and `charge_mode`)
- [ ] `handler/paymenthandler/stripe.go`: add `PushInvoice(chargeMode)` method (wraps Stripe Pay confirm + send)
- [ ] `model/payment/invoice.go`: carryover / paying CRUD + push_type determination + `billing_errors.PayingAlreadyActive`

**API**

- [ ] `api/stripe.go`: 3 endpoints (POST `/paying`, GET `/paying`, GET `/paying/{id}`) with swagger annotations
- [ ] Unit tests: API endpoints + 409 path + `month` format validation
- [ ] Run `make swagger` to regenerate OpenAPI spec (`docs/openapi/`, `docs/msp_openapi/`, `docs/partners_openapi/`, `docs/partners_private_api/`)

**Worker**

- [ ] `workers/stripe_invoice_paying_worker.go`: scans GENERATED + Stripe + payable + !internal invoices, applies the $1 rule, calls PushInvoice
- [ ] Register in `workers/worker.go` + add config keys

**Frontend + Monitoring + Audit**

- [ ] Finance backend dashboard + "Run Auto Payment Now" button (with `yyyy-mm` input)
- [ ] Prometheus metrics + AlertManager rules in runbooks repo
- [ ] auditlog events (3 types)

**Testing + Rollout**

- [ ] Unit tests + integration tests + mock Stripe (charge_mode double branch)
- [ ] End-to-end + dev / staging verification
- [ ] 3-phase rollout

---

## Change Log

| Date | Change |
|------|--------|
| 2026-08-04 | Architecture locked: DB paying table + 1-min polling + Finance button trigger (no middle confirm) + same-month uniqueness (409 reject) + 3 APIs + accumulation ≤ $1 + single-month auto-charge / accumulated manual |
| 2026-08-04 | Timing details: scan range includes `tenant.kind != internal` defensive filter; `PushInvoice` wraps Stripe Pay (confirm + send); unified naming as `paying` |
| 2026-08-04 | Simplified trigger: removed `CreateInvoiceWorker` and `billing_cycle_end` auto triggers; only Finance "Run Auto Payment Now" button remains |
| 2026-08-04 | API input simplified: `POST /paying` takes `month: "yyyy-mm"` directly; status renamed back to `completed` |
| 2026-08-04 | Readability review: merged `§Month Range` into `§Scan Range + Month Parsing`; simplified flow diagram; fixed `PushInvoice` receiver to `StripePaymentHandlerImpl`; added `§Acceptance Criteria` |
| 2026-08-12 | Push semantics documented: batch worker vs PUT /confirm differences (§Push Semantics) |

---

## Push Semantics

The batch worker and the confirm endpoint intentionally have different behavior:

| Path | Carryover pool? | minAmount gate? | Merge vs individual? |
|------|:---:|:---:|:---:|
| **Batch worker** (`StripeInvoicePayingWorker`) | Yes — pool items are fetched via `ListInvoicesByIDs` and included in the push | Yes — invoices below `stripe-push-min-amount-usd` stay in the carryover pool | Per-invoice when no carryover (`StripePayingPushPerInvoice=true`, default); **always merged when carryover is present** |
| **PUT /confirm** (`ConfirmAndPayInvoice`) | No — single-invoice only, no pool lookup | No — any amount is pushed immediately | Always per-invoice via `SendInvoice` |

- The **worker** accumulates small invoices in the carryover pool and pushes them in bulk once the threshold is crossed. This is the automated billing pipeline.
- **PUT /confirm** is an operator override: it pushes one specific invoice immediately regardless of amount, without touching the pool. Use it when ops needs to force-send an invoice that would otherwise wait for the next batch tick.
- Below the `stripe-push-min-amount-usd` threshold, invoices stay in the pool as `below_threshold_carry` and are re-evaluated each month until the accumulated total crosses the threshold or the 12-month expiry is reached (`stripe-push-max-carryover-months`).