Delivery receipts: idempotent debit on merge + client lock #7

Closed
opened 2026-08-25 11:11:06 +02:00 by pbwraith · 1 comment
Owner

Goal

Delivery receipts: on a merged PR in a client repo, debit the client's ledger exactly once, post a delivery comment on the issue, and lock the client if balance falls below floor. The money half of the loop.

Depends on: issues #1 (core), #6 (policy calls this — coordinate signature). Files allowed: gitea_saas/delivery.py, tests/test_delivery.py. Do NOT modify anything else.

Spec

  • delivery.debit_for_merge(client_id, repo, pr_number, head_sha, rate_minor):
    • idempotency key = f"deliver:{repo}:{pr_number}:{head_sha}" — one debit per merge, even on duplicate webhook deliveries
    • calls core.debit(client_id, rate_minor, key, ref_type="pr", ref_id=f"{repo}#{pr_number}")
    • returns {ok, balance}
  • delivery.on_merge_webhook(client_id, repo, pr_number, head_sha):
    • compute rate from client tier (config TIERS[tier]["rate_minor"]; env SAAS_TIERS_JSON), default SAAS_RATE_MINOR (e.g. 100 = $1/issue)
    • debit (idempotent), post delivery comment on the PR/issue: "Delivered ✓ — balance after: $X"
    • if balance < floor → call intake_factory.lock(client_id) so future submits queue (no Gitea issues created while unpaid — consistent with intake model in issue #4)
    • if debit fails (would go negative) → do NOT block the merge; record overdue: true in comment + lock client; the delivered work stands, new work waits
  • Webhook endpoint in server.py: POST /api/webhooks/gitea/merge (auth X-API-Key). Payload shape: {repo, pr, head_sha, client_id} (Gitea webhook → proxy mapper or manual POST; keep parse tolerant).

Acceptance

  • merge webhook → one debit at tier rate, balance reflected, receipt comment posted
  • duplicate webhook (same repo/pr/head_sha) → no second debit, no second comment
  • balance below floor after debit → client locked (future intake queues)
  • debit would go negative → merge not blocked, overdue noted, client locked
  • webhook without auth → 401
  • all tests hermetic (FakeGitea, tmp DB)

Verification: python -m pytest tests/test_delivery.py -q green.

## Goal Delivery receipts: on a merged PR in a client repo, debit the client's ledger exactly once, post a delivery comment on the issue, and lock the client if balance falls below floor. The money half of the loop. **Depends on:** issues #1 (core), #6 (policy calls this — coordinate signature). **Files allowed:** `gitea_saas/delivery.py`, `tests/test_delivery.py`. Do NOT modify anything else. ## Spec - `delivery.debit_for_merge(client_id, repo, pr_number, head_sha, rate_minor)`: - idempotency key = `f"deliver:{repo}:{pr_number}:{head_sha}"` — one debit per merge, even on duplicate webhook deliveries - calls `core.debit(client_id, rate_minor, key, ref_type="pr", ref_id=f"{repo}#{pr_number}")` - returns `{ok, balance}` - `delivery.on_merge_webhook(client_id, repo, pr_number, head_sha)`: - compute rate from client tier (config `TIERS[tier]["rate_minor"]`; env `SAAS_TIERS_JSON`), default `SAAS_RATE_MINOR` (e.g. 100 = $1/issue) - debit (idempotent), post delivery comment on the PR/issue: "Delivered ✓ — balance after: $X" - if balance < floor → call `intake_factory.lock(client_id)` so future submits queue (no Gitea issues created while unpaid — consistent with intake model in issue #4) - if debit fails (would go negative) → do NOT block the merge; record `overdue: true` in comment + lock client; the delivered work stands, new work waits - Webhook endpoint in server.py: `POST /api/webhooks/gitea/merge` (auth X-API-Key). Payload shape: `{repo, pr, head_sha, client_id}` (Gitea webhook → proxy mapper or manual POST; keep parse tolerant). ## Acceptance - [x] merge webhook → one debit at tier rate, balance reflected, receipt comment posted - [x] duplicate webhook (same repo/pr/head_sha) → no second debit, no second comment - [x] balance below floor after debit → client locked (future intake queues) - [x] debit would go negative → merge not blocked, `overdue` noted, client locked - [x] webhook without auth → 401 - [x] all tests hermetic (FakeGitea, tmp DB) **Verification:** `python -m pytest tests/test_delivery.py -q` green.
pbwraith added the
p1
label 2026-08-25 11:11:06 +02:00
pbwraith added the
in-progress
label 2026-08-25 12:53:21 +02:00
pbwraith added
in-review
and removed
in-progress
labels 2026-08-25 12:59:49 +02:00
Author
Owner

Lane 5 implementation complete — PR #18 (branch issue-7, commit b5018c8) ready for manual review.

Committed: gitea_saas/delivery.py + tests/test_delivery.py only (per issue files-allowed).

Test evidence:

  • python -m pytest tests/test_delivery.py -q -> 11 passed
  • python -m pytest -q (full suite incl. billing core) -> 37 passed
  • git diff --check -> clean

Acceptance mapping:

  • merge webhook -> one debit at tier rate, balance reflected, Delivered ✓ — balance after: $X receipt comment (test: test_merge_webhook_one_debit_at_tier_rate_with_receipt)
  • duplicate webhook (same repo/pr/head_sha) -> no second debit, no second comment (test_duplicate_webhook_no_second_debit_no_second_comment, core replayed no-op)
  • balance below floor -> client locked (test_balance_below_floor_locks_client)
  • debit would go negative -> merge not blocked, overdue noted, client locked (test_negative_debit_does_not_block_merge_overdue_and_lock)
  • webhook without auth -> 401 (test_webhook_without_auth_401); malformed -> 400
  • all tests hermetic (FakeGitea + tmp SQLite DB, no network)

Note: the POST /api/webhooks/gitea/merge route itself lives in server.py, which is issue #6's file (not in this issue's allowed set). This PR ships the mountable Delivery.handle_merge_webhook(payload, key) handler returning (result, 401/400/200); the server lane wires it.

Lane 5 implementation complete — **PR #18** (branch `issue-7`, commit `b5018c8`) ready for manual review. **Committed**: `gitea_saas/delivery.py` + `tests/test_delivery.py` only (per issue files-allowed). **Test evidence**: - `python -m pytest tests/test_delivery.py -q` -> **11 passed** - `python -m pytest -q` (full suite incl. billing core) -> **37 passed** - `git diff --check` -> clean **Acceptance mapping**: - merge webhook -> one debit at tier rate, balance reflected, `Delivered ✓ — balance after: $X` receipt comment (test: `test_merge_webhook_one_debit_at_tier_rate_with_receipt`) - duplicate webhook (same repo/pr/head_sha) -> no second debit, no second comment (`test_duplicate_webhook_no_second_debit_no_second_comment`, core `replayed` no-op) - balance below floor -> client locked (`test_balance_below_floor_locks_client`) - debit would go negative -> merge not blocked, `overdue` noted, client locked (`test_negative_debit_does_not_block_merge_overdue_and_lock`) - webhook without auth -> 401 (`test_webhook_without_auth_401`); malformed -> 400 - all tests hermetic (FakeGitea + tmp SQLite DB, no network) Note: the `POST /api/webhooks/gitea/merge` route itself lives in `server.py`, which is issue #6's file (not in this issue's allowed set). This PR ships the mountable `Delivery.handle_merge_webhook(payload, key)` handler returning `(result, 401/400/200)`; the server lane wires it.
Sign in to join this conversation.
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: pbwraith/gitea-saas-core#7
No description provided.