Stripe reconciliation (daily) #29
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Stripe reconciliation (daily) | |
| # P3.RAIL2 — Reconciles the SettleGrid unified ledger against Stripe | |
| # Balance Transactions + Connect Transfers for the previous UTC | |
| # calendar day. Reports drift to data/reconciliation/stripe/{date}.json, | |
| # posts a one-line summary to Slack/Discord, and opens a rate-limited | |
| # GitHub issue when drift breaches the configured threshold (1% / 100 | |
| # bps by default). The orchestrator caps GitHub issues at one per | |
| # 24h via .reconcile-state.json, so a 24h Stripe outage that produces | |
| # 24 daily drift reports opens AT MOST one issue. | |
| # | |
| # Hostile-lens posture: | |
| # (a) Schedule is FIXED at 08:00 UTC — well after Stripe's UTC-day | |
| # window closes, so the run sees a complete day. | |
| # (b) Workflow runs on the default branch only and uses the | |
| # repository's default GITHUB_TOKEN scopes. No third-party | |
| # actions handle secrets. | |
| # (c) workflow_dispatch input is allowed for ad-hoc backfills, | |
| # but the script validates --date through the same | |
| # utcDayBounds() guard the cron path uses. | |
| # (d) The job commits its outputs (data/reconciliation/stripe/* | |
| # and data/reconciliation/.reconcile-state.json) so the | |
| # audit trail lives in git, not action artifacts. | |
| on: | |
| schedule: | |
| # Daily 08:00 UTC. Verifier check 17 expects this exact cron string. | |
| - cron: '0 8 * * *' | |
| workflow_dispatch: | |
| inputs: | |
| date: | |
| description: 'UTC calendar day to reconcile (YYYY-MM-DD). Empty → yesterday UTC.' | |
| required: false | |
| default: '' | |
| dry_run: | |
| description: 'Skip DB / Stripe / disk / webhook calls.' | |
| required: false | |
| default: 'false' | |
| type: boolean | |
| permissions: | |
| # `contents: write` is reserved for the opt-in auto-push step | |
| # (gated by vars.RECONCILE_AUTO_PUSH). When the variable is unset, | |
| # the step is skipped and the token is unused. | |
| contents: write | |
| issues: write | |
| concurrency: | |
| # One reconciliation at a time. A 2nd manual run while a cron run is | |
| # in flight queues rather than racing the state file. | |
| group: stripe-reconciliation | |
| cancel-in-progress: false | |
| jobs: | |
| reconcile: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| env: | |
| DATABASE_URL: ${{ secrets.RECONCILE_DATABASE_URL }} | |
| # Per spec: use a Stripe restricted key with | |
| # rak_balance_transaction_read + rak_transfer_read scopes only. | |
| # Repo secret name = STRIPE_RECONCILE_KEY (rotate independently | |
| # of the platform STRIPE_SECRET_KEY). | |
| STRIPE_RECONCILE_KEY: ${{ secrets.STRIPE_RECONCILE_KEY }} | |
| SLACK_RECONCILE_WEBHOOK: ${{ secrets.SLACK_RECONCILE_WEBHOOK }} | |
| DISCORD_RECONCILE_WEBHOOK: ${{ secrets.DISCORD_RECONCILE_WEBHOOK }} | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| RECONCILE_REPO_SLUG: ${{ github.repository }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| # Auto-push is opt-in (see step below). Default checkout is | |
| # shallow; deepen only if we actually intend to push. | |
| fetch-depth: 1 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| cache: 'npm' | |
| - run: npm ci | |
| - name: Build @settlegrid/rails | |
| run: npm --workspace @settlegrid/rails run build | |
| - name: Run reconciliation | |
| # Bind workflow_dispatch inputs to env vars instead of pasting | |
| # them via `${{ ... }}` template substitution. The `${{ }}` | |
| # form is expanded by GitHub BEFORE the shell sees it, so a | |
| # malicious `date: 2026-04-23 && rm -rf /` would inject. The | |
| # env-var form passes the value through `process.env` and the | |
| # shell's quoting; safe. | |
| env: | |
| INPUT_DATE: ${{ github.event.inputs.date }} | |
| INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }} | |
| run: | | |
| set -euo pipefail | |
| ARGS=() | |
| if [[ -n "${INPUT_DATE:-}" ]]; then | |
| # Reject anything but YYYY-MM-DD up-front so we never feed | |
| # an unvalidated string to the script even on a misconfigured | |
| # input. The script also re-validates via utcDayBounds. | |
| if ! [[ "${INPUT_DATE}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then | |
| echo "Invalid date input: ${INPUT_DATE}" >&2 | |
| exit 2 | |
| fi | |
| ARGS+=(--date "${INPUT_DATE}") | |
| fi | |
| if [[ "${INPUT_DRY_RUN:-false}" == "true" ]]; then | |
| ARGS+=(--dry-run) | |
| fi | |
| npx tsx scripts/reconcile-stripe.ts "${ARGS[@]}" | |
| - name: Upload reconciliation report | |
| if: ${{ github.event.inputs.dry_run != 'true' }} | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: stripe-reconciliation-${{ github.run_id }} | |
| path: data/reconciliation/ | |
| retention-days: 90 | |
| if-no-files-found: warn | |
| - name: Commit reconciliation report and state (opt-in) | |
| # Auto-push is OPT-IN via the `RECONCILE_AUTO_PUSH` repo | |
| # variable. Default-off because pushing data files to the | |
| # default branch triggers Vercel rebuilds on every nightly | |
| # run, which burns the deploy budget. Operators who need an | |
| # in-git audit trail can set | |
| # `vars.RECONCILE_AUTO_PUSH=true` in the repo's "Variables" | |
| # tab; the commit-and-push path will then run. | |
| if: ${{ github.event.inputs.dry_run != 'true' && vars.RECONCILE_AUTO_PUSH == 'true' }} | |
| env: | |
| REF_NAME: ${{ github.ref_name }} | |
| run: | | |
| set -euo pipefail | |
| if [[ -z "$(git status --porcelain data/reconciliation/)" ]]; then | |
| echo "No reconciliation changes to commit." | |
| exit 0 | |
| fi | |
| git config user.name 'settlegrid-bot' | |
| git config user.email 'bot@settlegrid.dev' | |
| git add data/reconciliation/ | |
| git commit -m "chore(reconcile): nightly Stripe reconciliation $(date -u +%Y-%m-%d)" | |
| git push origin "HEAD:${REF_NAME}" |