{"id":51119061,"url":"https://github.com/sulthonzh/subscription-reconciler","last_synced_at":"2026-06-25T00:30:40.616Z","repository":{"id":361040782,"uuid":"1252653105","full_name":"sulthonzh/subscription-reconciler","owner":"sulthonzh","description":"Premium entitlement reconciler for multi-channel subscriptions — in-app, carrier, marketplace. 100% coverage.","archived":false,"fork":false,"pushed_at":"2026-06-15T03:58:20.000Z","size":190,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-15T05:24:48.748Z","etag":null,"topics":["api","backend","entitlements","go","payments","reconciler","sqlite","subscriptions"],"latest_commit_sha":null,"homepage":null,"language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/sulthonzh.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-28T18:30:51.000Z","updated_at":"2026-06-15T03:58:25.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/sulthonzh/subscription-reconciler","commit_stats":null,"previous_names":["sulthonzh/subscription-reconciler"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/sulthonzh/subscription-reconciler","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sulthonzh%2Fsubscription-reconciler","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sulthonzh%2Fsubscription-reconciler/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sulthonzh%2Fsubscription-reconciler/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sulthonzh%2Fsubscription-reconciler/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/sulthonzh","download_url":"https://codeload.github.com/sulthonzh/subscription-reconciler/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sulthonzh%2Fsubscription-reconciler/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34755061,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-24T02:00:07.484Z","response_time":106,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["api","backend","entitlements","go","payments","reconciler","sqlite","subscriptions"],"created_at":"2026-06-25T00:30:40.548Z","updated_at":"2026-06-25T00:30:40.607Z","avatar_url":"https://github.com/sulthonzh.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Subscription Reconciler\n\nPremium entitlement reconciler for multi-channel subscription management. Ingests signals from in-app store (webhooks), mobile carrier (polling), and third-party marketplace (bulk revoke) to maintain canonical premium access state per user.\n\n\u003cp align=\"center\"\u003e\n  \u003cimg src=\"https://img.shields.io/badge/Go-1.26.2-00ADD8?logo=go\u0026style=flat-square\" alt=\"Go Version\"\u003e\n  \u003cimg src=\"https://img.shields.io/badge/SQLite-pure%20Go-003B57?logo=sqlite\u0026style=flat-square\" alt=\"SQLite\"\u003e\n  \u003cimg src=\"https://img.shields.io/badge/docker-ready-2496ED?logo=docker\u0026style=flat-square\" alt=\"Docker\"\u003e\n  \u003cimg src=\"https://img.shields.io/badge/chi-v5.3-00ADD8?style=flat-square\" alt=\"Chi\"\u003e\n  \u003cimg src=\"https://img.shields.io/badge/license-MIT-blue?style=flat-square\" alt=\"License\"\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003cimg src=\"https://img.shields.io/badge/coverage-100.0%25-brightgreen?style=flat-square\" alt=\"Overall Coverage\"\u003e\n  \u003cimg src=\"https://img.shields.io/badge/domain-100.0%25-brightgreen?style=flat-square\" alt=\"Domain Coverage\"\u003e\n  \u003cimg src=\"https://img.shields.io/badge/httphandler-100.0%25-brightgreen?style=flat-square\" alt=\"HTTP Handler Coverage\"\u003e\n  \u003cimg src=\"https://img.shields.io/badge/service-100.0%25-brightgreen?style=flat-square\" alt=\"Service Coverage\"\u003e\n  \u003cimg src=\"https://img.shields.io/badge/carrierhttp-100.0%25-brightgreen?style=flat-square\" alt=\"Carrier HTTP Coverage\"\u003e\n  \u003cimg src=\"https://img.shields.io/badge/sqlite-100.0%25-brightgreen?style=flat-square\" alt=\"SQLite Coverage\"\u003e\n  \u003cimg src=\"https://img.shields.io/badge/middleware-100.0%25-brightgreen?style=flat-square\" alt=\"Middleware Coverage\"\u003e\n\u003c/p\u003e\n\n---\n\n## Table of Contents\n\n- [Overview](#overview)\n- [Quick Start](#quick-start)\n- [Architecture](#architecture)\n- [API Reference](#api-reference)\n- [Domain Model](#domain-model)\n- [Database Schema](#database-schema)\n- [Background Workers](#background-workers)\n- [Middleware Stack](#middleware-stack)\n- [Configuration](#configuration)\n- [Testing](#testing)\n- [Deployment](#deployment)\n- [Tech Stack](#tech-stack)\n\n## Overview\n\nSubscription Reconciler solves the complex problem of managing premium entitlements across multiple channels. Modern subscription services receive purchase signals from various sources — in-app store webhooks, mobile carrier billing, and third-party marketplaces. Each source operates independently, leading to potential inconsistencies in user access status.\n\nThis system maintains a **canonical entitlement state per user** by ingesting signals from all channels and applying a deterministic resolution priority. It handles state transitions through a robust event processing system, manages background cleanup of expired entitlements, and provides a reliable API for checking user premium status.\n\nBuilt with a **hexagonal architecture** using ports and adapters, ensuring business logic remains independent from external concerns like databases and HTTP transports.\n\n### Key Features\n\n- **Multi-source entitlement resolution** with deterministic priority (STORE \u003e MARKETPLACE \u003e CARRIER)\n- **Idempotent webhook processing** with event deduplication\n- **Carrier polling** for subscription status synchronization\n- **Marketplace bulk revocation** for third-party cancellations\n- **Proactive notification scheduling** (24-hour expiry warnings)\n- **Audit timeline** for full entitlement history\n- **Background workers** for expiry cleanup and notification dispatch\n- **Production middleware** (rate limiting, body size limits, CORS, structured logging)\n\n## Quick Start\n\n**Docker (recommended):**\n\n```bash\ndocker compose up --build\n```\n\nThe server starts on `:8080` with mock carrier on `:8081`.\n\n**Verify it's running:**\n\n```bash\ncurl http://localhost:8080/health\n# {\"status\":\"ok\"}\n```\n\n**Store Webhook Example:**\n\n```bash\ncurl -X POST http://localhost:8080/webhooks/store \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"eventId\": \"evt_abc123\",\n    \"userId\": \"u_42\",\n    \"type\": \"INITIAL_PURCHASE\",\n    \"eventTimeMs\": 1716700000000,\n    \"productId\": \"premium_monthly\"\n  }'\n# {\"status\":\"processed\"}\n```\n\n**Check Entitlement:**\n\n```bash\ncurl http://localhost:8080/users/u_42/entitlement\n# {\"active\":true,\"source\":\"STORE\",\"expiresAt\":\"2024-05-29T06:26:40Z\",...}\n```\n\n**Local Development:**\n\n```bash\nmake run\n```\n\n**Development Commands:**\n\n```bash\nmake build          # Compile binary\nmake test           # Run tests with race detector and coverage\nmake run            # Run locally\nmake docker         # Build and run with Docker Compose\nmake clean          # Remove build artifacts and DB files\n```\n\n## Architecture\n\n```mermaid\nflowchart TB\n    subgraph drivers[\"Driving Adapters (Inbound)\"]\n        direction TB\n        MW[\"Middleware Stack\u003cbr/\u003eRequestID → RateLimiter\u003cbr/\u003eBodySizeLimit → CORS → Logger → Recoverer\"]\n        HTTP[\"HTTP Handler\u003cbr/\u003e\u003ci\u003echi router\u003c/i\u003e\"]\n        MW --\u003e HTTP\n    end\n\n    subgraph workers[\"Background Workers\"]\n        direction LR\n        CP[\"Carrier Poller\u003cbr/\u003e\u003ci\u003eevery 5m\u003c/i\u003e\"]\n        NS[\"Notification Scheduler\u003cbr/\u003e\u003ci\u003eevery 5m\u003c/i\u003e\"]\n        NT[\"Notifier\u003cbr/\u003e\u003ci\u003eevery 1m\u003c/i\u003e\"]\n        ES[\"Expiry Sweeper\u003cbr/\u003e\u003ci\u003eevery 5m\u003c/i\u003e\"]\n    end\n\n    subgraph core[\"Application Core\"]\n        direction TB\n        SVC[\"Reconciler Service\u003cbr/\u003e\u003ci\u003euse-case orchestration\u003c/i\u003e\"]\n        DOM[\"Domain Layer\u003cbr/\u003e\u003ci\u003estate machine · resolution\u003cbr/\u003eproduct catalog · audit\u003c/i\u003e\"]\n        PORT[\"Port Interfaces\u003cbr/\u003e\u003ci\u003erepository contracts\u003c/i\u003e\"]\n        SVC --\u003e DOM\n        SVC --\u003e PORT\n    end\n\n    subgraph adapters[\"Driven Adapters (Outbound)\"]\n        direction LR\n        SQLITE[\"SQLite Repository\u003cbr/\u003e\u003ci\u003epersistence\u003c/i\u003e\"]\n        CARRIER[\"Carrier HTTP Client\u003cbr/\u003e\u003ci\u003eexternal API\u003c/i\u003e\"]\n    end\n\n    HTTP --\u003e |\"POST /webhooks/store\"| SVC\n    HTTP --\u003e |\"POST /webhooks/marketplace/revoke\"| SVC\n    HTTP --\u003e |\"GET /users/:id/entitlement\"| SVC\n    HTTP --\u003e |\"GET /users/:id/timeline\"| SVC\n    HTTP --\u003e |\"GET /health\"| OK[\"200 OK\"]\n\n    CP --\u003e |\"poll status\"| SVC\n    NS --\u003e |\"schedule warnings\"| PORT\n    NT --\u003e |\"dispatch pending\"| PORT\n    ES --\u003e |\"expire overdue\"| PORT\n\n    SVC --\u003e |\"read/write\"| SQLITE\n    CP --\u003e |\"GET /mock/carrier/plan\"| CARRIER\n    CARRIER -.-\u003e |\"carrier response\"| SVC\n\n    SQLITE -.-\u003e |\"implements\"| PORT\n```\n\n### Request Flow\n\n```mermaid\nsequenceDiagram\n    participant C as Client\n    participant MW as Middleware\n    participant H as HTTP Handler\n    participant S as Reconciler Service\n    participant D as Domain Layer\n    participant R as SQLite Repository\n\n    C-\u003e\u003eMW: HTTP Request\n    MW-\u003e\u003eMW: RequestID → RateLimiter\n    MW-\u003e\u003eMW: BodySizeLimit → CORS → Logger → Recoverer\n    MW-\u003e\u003eH: Forward request\n    H-\u003e\u003eH: Validate \u0026 parse payload\n    H-\u003e\u003eS: Call use-case method\n    S-\u003e\u003eD: ApplyStoreEvent / ResolveEntitlements\n    D--\u003e\u003eS: Updated entitlement state\n    S-\u003e\u003eR: Persist changes\n    R--\u003e\u003eS: Confirmation\n    S--\u003e\u003eH: Result\n    H--\u003e\u003eC: JSON response\n```\n\n### Hexagonal (Ports \u0026 Adapters)\n\nThe codebase follows a **ports-and-adapters** pattern:\n\n| Layer | Package | Role |\n|-------|---------|------|\n| Domain | `internal/domain/` | Business entities, state machine, entitlement resolution |\n| Ports | `internal/port/` | Interfaces defining contracts between layers |\n| Services | `internal/service/` | Use-case orchestration (reconciler) |\n| Adapters (driven) | `internal/adapter/sqlite/` | SQLite persistence implementation |\n| Adapters (driving) | `internal/adapter/httphandler/` | HTTP handler + router |\n| Adapters (external) | `internal/adapter/carrierhttp/` | Carrier API client |\n| Middleware | `internal/middleware/` | Rate limiting, body size, CORS, logging |\n| Entry | `cmd/server/` | Server wiring, DB migrations, graceful shutdown |\n\n### Design Decisions\n\n- **Inline SQL migrations** — no external migration tool; pure Go, no CGO dependency\n- **Multi-row entitlement model** — one row per `(user_id, source)`, enabling independent source tracking\n- **Priority-based resolution** — STORE \u003e MARKETPLACE \u003e CARRIER; highest-priority active source wins\n- **Context-based transactions** — repositories detect active transaction from context, fallback to `*sql.DB`\n- **Out-of-order event guard** — store events with `eventTimeMs` older than existing `LastEventTimeMs` are silently ignored to prevent stale overwrites\n\n## API Reference\n\nAll endpoints return `Content-Type: application/json`.\n\n### Health Check\n\n```\nGET /health\n```\n\n**Response** `200`:\n```json\n{\n  \"status\": \"ok\"\n}\n```\n\n---\n\n### Check Entitlement\n\nReturns the resolved premium entitlement for a user. **Always returns 200** — even if the user has no entitlements (fields will be null/defaults).\n\n```\nGET /users/{userId}/entitlement\n```\n\n**Response** `200`:\n```json\n{\n  \"active\": true,\n  \"source\": \"STORE\",\n  \"expiresAt\": \"2024-05-29T06:26:40Z\",\n  \"lastChangedAt\": \"2024-05-28T06:26:40Z\",\n  \"reason\": \"INITIAL_PURCHASE\"\n}\n```\n\n**Fields:**\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `active` | `bool` | Whether the user has active premium access |\n| `source` | `string` | Highest-priority active source (`STORE`, `MARKETPLACE`, `CARRIER`), or `NONE` if no entitlements |\n| `expiresAt` | `string\\|null` | ISO 8601 timestamp when entitlement expires |\n| `lastChangedAt` | `string\\|null` | ISO 8601 timestamp of last state change |\n| `reason` | `string\\|null` | The event type that caused the current state |\n\n**When user has no entitlements:**\n```json\n{\n  \"active\": false,\n  \"source\": \"NONE\",\n  \"expiresAt\": null,\n  \"lastChangedAt\": null,\n  \"reason\": null\n}\n```\n\n---\n\n### Get Timeline\n\nReturns the audit trail of all entitlement state transitions for a user. Returns an empty array `[]` if no history exists.\n\n```\nGET /users/{userId}/timeline\n```\n\n**Response** `200`:\n```json\n[\n  {\n    \"triggerId\": \"evt_abc123\",\n    \"source\": \"STORE\",\n    \"previousState\": \"\",\n    \"nextState\": \"{\\\"active\\\":true,\\\"source\\\":\\\"STORE\\\",\\\"reason\\\":\\\"INITIAL_PURCHASE\\\"}\",\n    \"createdAt\": \"2024-05-28T06:26:40Z\"\n  },\n  {\n    \"triggerId\": \"evt_def456\",\n    \"source\": \"STORE\",\n    \"previousState\": \"{\\\"active\\\":true,\\\"source\\\":\\\"STORE\\\",\\\"reason\\\":\\\"RENEWAL\\\"}\",\n    \"nextState\": \"{\\\"active\\\":false,\\\"source\\\":\\\"STORE\\\",\\\"reason\\\":\\\"EXPIRATION\\\"}\",\n    \"createdAt\": \"2024-05-29T06:26:40Z\"\n  }\n]\n```\n\n**Fields:**\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `triggerId` | `string` | The event ID that caused the transition |\n| `source` | `string` | Source of the triggering event |\n| `previousState` | `string` | JSON-encoded state before transition (empty string for new entitlements) |\n| `nextState` | `string` | JSON-encoded state after transition |\n| `createdAt` | `string` | ISO 8601 timestamp of the transition |\n\n---\n\n### Store Webhook\n\nProcesses store subscription events (Apple App Store / Google Play). Idempotent — duplicate `eventId` values are ignored.\n\n```\nPOST /webhooks/store\n```\n\n**Request:**\n```json\n{\n  \"eventId\": \"evt_abc123\",\n  \"userId\": \"u_42\",\n  \"type\": \"INITIAL_PURCHASE\",\n  \"eventTimeMs\": 1716700000000,\n  \"productId\": \"premium_monthly\"\n}\n```\n\n**Fields:**\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `eventId` | `string` | ✅ | Unique event identifier (dedup key) |\n| `userId` | `string` | ✅ | User identifier |\n| `type` | `string` | ✅ | Event type (see below) |\n| `eventTimeMs` | `number` | ✅ | Event timestamp in milliseconds since epoch |\n| `productId` | `string` | ✅ | Product identifier |\n\n**Event Types:**\n\n| Type | Active After | Effect |\n|------|:----------:|--------|\n| `INITIAL_PURCHASE` | ✅ | Creates and activates entitlement for product duration |\n| `RENEWAL` | ✅ | Extends entitlement by product duration from event time |\n| `CANCELLATION` | ✅ | Updates reason only; access continues until `expires_at` |\n| `BILLING_ISSUE` | — | Informational; no state change, reason updated |\n| `EXPIRATION` | ❌ | Deactivates the entitlement |\n| `UN_CANCELLATION` | ✅ | Re-activates with new product duration |\n\n**Response** `200`:\n```json\n{\"status\": \"processed\"}\n```\n\n**Response (duplicate event)** `200`:\n```json\n{\"status\": \"ignored\"}\n```\n\n**Response (validation error)** `400`:\n```json\n{\"error\": \"all fields are required\"}\n```\n\n**Response (unknown product)** `400`:\n```json\n{\"error\": \"unknown product ID\"}\n```\n\n---\n\n### Marketplace Bulk Revoke\n\nRevokes premium access for a list of users from marketplace channels.\n\n```\nPOST /webhooks/marketplace/revoke\n```\n\n**Request:**\n```json\n{\n  \"userIds\": [\"u_42\", \"u_99\", \"u_55\"]\n}\n```\n\n**Response** `200`:\n```json\n{\n  \"revoked\": 2,\n  \"skipped\": 1\n}\n```\n\n**Fields:**\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `revoked` | `number` | Number of users whose entitlement was actually revoked |\n| `skipped` | `number` | Number of users who had no marketplace entitlement to revoke |\n\n**Response (validation error)** `400`:\n```json\n{\"error\": \"userIds must be non-empty\"}\n```\n\n## Domain Model\n\n### Entitlement\n\nThe core entity representing a user's premium access from a single source.\n\n```go\ntype Entitlement struct {\n    UserID          string\n    Source          Source    // STORE, CARRIER, MARKETPLACE\n    Active          bool\n    ExpiresAt       *time.Time\n    LastEventTimeMs int64     // Timestamp from the source event\n    LastChangedAt   time.Time\n    Reason          string    // Event type that caused current state\n    CreatedAt       time.Time\n}\n```\n\n**Primary Key:** `(user_id, source)` — each user can have up to 3 independent entitlement rows.\n\n### Source Priority\n\nWhen resolving a user's overall premium status:\n\n```mermaid\nflowchart LR\n    subgraph sources[\"Sources (priority order)\"]\n        direction LR\n        S1[\"🥇 STORE\"]\n        S2[\"🥈 MARKETPLACE\"]\n        S3[\"🥉 CARRIER\"]\n    end\n\n    S1 --\u003e |\"active?\"| RESOLVED[\"✅ Resolved\"]\n    S1 --\u003e |\"inactive\"| S2\n    S2 --\u003e |\"active?\"| RESOLVED\n    S2 --\u003e |\"inactive\"| S3\n    S3 --\u003e |\"active?\"| RESOLVED\n    S3 --\u003e |\"inactive\"| NONE[\"❌ No premium\"]\n```\n\nThe system checks each source in priority order. The first active entitlement found becomes the resolved entitlement. If no source is active, the user has no premium access.\n\n### State Machine\n\nEach entitlement row follows a state machine governed by the `Active` boolean:\n\n```mermaid\nstateDiagram-v2\n    direction TB\n\n    [*] --\u003e INACTIVE : Default state\n\n    INACTIVE --\u003e ACTIVE : INITIAL_PURCHASE\n    ACTIVE --\u003e ACTIVE : RENEWAL\\n(extends expires_at)\n    ACTIVE --\u003e INACTIVE : EXPIRATION\n    ACTIVE --\u003e ACTIVE : CANCELLATION\\n(updates reason only,\\naccess until expires_at)\n    ACTIVE --\u003e ACTIVE : UN_CANCELLATION\\n(reactivates + new expires_at)\n    ACTIVE --\u003e ACTIVE : BILLING_ISSUE\\n(informational only,\\nno state change)\n```\n\n### Products\n\n| Product ID | Duration | Description |\n|------------|----------|-------------|\n| `premium_monthly` | 30 days | Monthly premium subscription |\n| `premium_yearly` | 365 days | Annual premium subscription |\n\nUnknown product IDs are rejected with a validation error.\n\n### Audit Entry\n\nEvery state transition is recorded:\n\n```go\ntype AuditEntry struct {\n    ID            int64\n    UserID        string\n    TriggerID     string   // The eventId or \"carrier_poll\" or \"marketplace_revoke\"\n    Source        Source\n    PreviousState string\n    NextState     string\n    CreatedAt     time.Time\n}\n```\n\n## Database Schema\n\nSQLite with pure-Go driver (`modernc.org/sqlite`). All timestamps stored as TEXT in ISO 8601 format using `strftime`.\n\n### `entitlements`\n\n| Column | Type | Description |\n|--------|------|-------------|\n| `user_id` | `TEXT` | User identifier (PK part 1) |\n| `source` | `TEXT` | Entitlement source: `STORE`, `CARRIER`, `MARKETPLACE` (PK part 2) |\n| `active` | `BOOLEAN` | Whether entitlement is currently active |\n| `expires_at` | `TEXT` | ISO 8601 expiry timestamp, nullable |\n| `last_event_time_ms` | `INTEGER` | Source event timestamp in ms (used for stale detection) |\n| `last_changed_at` | `TEXT` | ISO 8601 timestamp of last state change |\n| `reason` | `TEXT` | Event type that caused current state, nullable |\n\n**Primary Key:** `(user_id, source)`\n\n### `store_events`\n\n| Column | Type | Description |\n|--------|------|-------------|\n| `event_id` | `TEXT` | Unique event identifier (PK, dedup key) |\n| `user_id` | `TEXT` | User identifier |\n| `type` | `TEXT` | Event type |\n| `event_time_ms` | `INTEGER` | Event timestamp from source |\n| `product_id` | `TEXT` | Product identifier |\n| `processed_at` | `TEXT` | ISO 8601 server processing timestamp |\n\n**Primary Key:** `(event_id)`\n\n### `carrier_poll_log`\n\n| Column | Type | Description |\n|--------|------|-------------|\n| `id` | `INTEGER` | Auto-increment PK |\n| `user_id` | `TEXT` | User identifier |\n| `status` | `TEXT` | Carrier's reported status (`active`, `inactive`, `api_error`) |\n| `locked_until` | `TEXT` | ISO 8601 lock expiry for dedup, nullable |\n| `polled_at` | `TEXT` | ISO 8601 poll timestamp |\n\n### `notifications`\n\n| Column | Type | Description |\n|--------|------|-------------|\n| `id` | `INTEGER` | Auto-increment PK |\n| `user_id` | `TEXT` | Target user |\n| `type` | `TEXT` | Notification type (default: `PREMIUM_EXPIRES_SOON`) |\n| `scheduled_for` | `TEXT` | ISO 8601 scheduled send time |\n| `sent_at` | `TEXT` | ISO 8601 actual send time, nullable |\n| `created_at` | `TEXT` | ISO 8601 creation timestamp |\n\n**Unique Constraint:** `(user_id, type, scheduled_for)` — prevents duplicate scheduling\n\n### `audit_log`\n\n| Column | Type | Description |\n|--------|------|-------------|\n| `id` | `INTEGER` | Auto-increment PK |\n| `user_id` | `TEXT` | User identifier |\n| `trigger_id` | `TEXT` | Source event or action identifier |\n| `source` | `TEXT` | Event source |\n| `previous_state` | `TEXT` | State before transition |\n| `next_state` | `TEXT` | State after transition |\n| `created_at` | `TEXT` | ISO 8601 transition timestamp |\n\n### Migrations\n\nAll schema migrations run inline at server startup in `cmd/server/main.go`. No external migration tool required. Uses `IF NOT EXISTS` guards for idempotent startup.\n\n```go\n// Example from main.go\nCREATE TABLE IF NOT EXISTS entitlements (\n    user_id TEXT NOT NULL,\n    source  TEXT NOT NULL,\n    active  BOOLEAN NOT NULL DEFAULT FALSE,\n    ...\n    PRIMARY KEY (user_id, source)\n);\n```\n\n## Background Workers\n\nFour background goroutines run alongside the HTTP server:\n\n```mermaid\nflowchart TB\n    subgraph workers[\"Background Workers\"]\n        direction TB\n        CP[\"Carrier Poller\u003cbr/\u003e\u003cb\u003eevery 5m\u003c/b\u003e\"]\n        NS[\"Notification Scheduler\u003cbr/\u003e\u003cb\u003eevery 5m\u003c/b\u003e\"]\n        NT[\"Notifier\u003cbr/\u003e\u003cb\u003eevery 1m\u003c/b\u003e\"]\n        ES[\"Expiry Sweeper\u003cbr/\u003e\u003cb\u003eevery 5m\u003c/b\u003e\"]\n    end\n\n    DB[(\"SQLite Database\")]\n\n    CP --\u003e |\"GET /mock/carrier/plan?userId=\"| CARRIER[\"Carrier API\"]\n    CARRIER --\u003e |\"status response\"| CP\n    CP --\u003e |\"upsert entitlement\"| DB\n\n    NS --\u003e |\"find expiring within 24h\"| DB\n    NS --\u003e |\"insert PREMIUM_EXPIRES_SOON\"| DB\n\n    NT --\u003e |\"find pending \u0026 due\"| DB\n    NT --\u003e |\"mark sent\"| DB\n\n    ES --\u003e |\"find active \u0026 expired\"| DB\n    ES --\u003e |\"set active=false\"| DB\n```\n\n### 1. Carrier Poller (5-minute interval)\n\nPolls the carrier API for all known carrier-entitled users. Updates entitlement state based on carrier response.\n\n```\nEvery 5 minutes:\n  → Fetch all users with CARRIER source entitlements\n  → For each user, call GET {CARRIER_URL}/mock/carrier/plan?userId={userId}\n  → Upsert entitlement based on carrier response\n  → Log poll result to carrier_poll_log\n  → Record audit entry if state changed\n```\n\n**Out-of-order guard:** Store events have a newer-than guard — if `event.EventTimeMs` is older than the existing `LastEventTimeMs`, the event is silently ignored. This prevents stale events from overwriting newer state (see `reconciler.go`).\n\n**Distributed locking:** Each poll acquires a `locked_until` row lock (2-minute TTL) via `AcquireLock` to prevent concurrent duplicate polling across multiple instances.\n\n### 2. Notification Scheduler (5-minute interval)\n\nProactively schedules expiry warning notifications for users whose entitlements expire within 24 hours.\n\n```\nEvery 5 minutes:\n  → Query all active entitlements expiring within 24 hours\n  → For each, create notification with type PREMIUM_EXPIRES_SOON\n  → UNIQUE(user_id, type, scheduled_for) constraint prevents duplicates\n  → scheduled_for = expires_at - 24h (clamped to now if already past)\n```\n\n### 3. Notifier (1-minute interval)\n\nDispatches pending notifications that have reached their scheduled send time.\n\n```\nEvery 1 minute:\n  → Query notifications where sent_at IS NULL AND scheduled_for \u003c= now\n  → For each notification, log dispatch (stdout in current implementation)\n  → Set sent_at = now to mark as dispatched\n```\n\n### 4. Expiry Sweeper (5-minute interval)\n\nScans for entitlements that have passed their expiry time and deactivates them.\n\n```\nEvery 5 minutes:\n  → Bulk UPDATE active entitlements where expires_at \u003c now\n  → Set active = false, reason = \"EXPIRED\", update last_changed_at\n  → Returns count of expired rows (no per-row audit entries)\n```\n\nAll workers start in `cmd/server/main.go` via goroutines with context-based cancellation for graceful shutdown.\n\n## Middleware Stack\n\nApplied in order (outermost first):\n\n```mermaid\nflowchart LR\n    REQ[\"Incoming\u003cbr/\u003eRequest\"] --\u003e RID[\"RequestID\"]\n    RID --\u003e RL[\"RateLimiter\u003cbr/\u003e\u003ci\u003e100 req/min per IP\u003c/i\u003e\"]\n    RL --\u003e BSL[\"BodySizeLimit\u003cbr/\u003e\u003ci\u003e≤ 1 MB\u003c/i\u003e\"]\n    BSL --\u003e CORS[\"CORS\"]\n    CORS --\u003e LOG[\"RequestLogger\"]\n    LOG --\u003e REC[\"Recoverer\"]\n    REC --\u003e HANDLER[\"Handler\"]\n\n    RL --\u003e |\"429 Too Many Requests\"| REJECT[\"❌ Rejected\"]\n    BSL --\u003e |\"413 Payload Too Large\"| REJECT\n```\n\n| Middleware | Package | Description |\n|------------|---------|-------------|\n| **RequestID** | chi | Generates unique `X-Request-Id` header for request tracing |\n| **RateLimiter** | `middleware/` | Per-IP rate limiting (100 req/min). Uses `net.SplitHostPort` to extract IP from `RemoteAddr` |\n| **BodySizeLimit** | `middleware/` | Rejects requests with body \u003e 1MB via `io.LimitReader` + `io.ReadAll` |\n| **CORS** | `middleware/` | Custom CORS — allows all origins, GET/POST/OPTIONS, Content-Type + Authorization headers |\n| **RequestLogger** | `middleware/` | Structured request logging via slog (method, path, status, duration) |\n| **Recoverer** | chi | Catches panics, returns 500 with stack trace in logs |\n\n## Configuration\n\nAll configuration via environment variables:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `PORT` | `8080` | HTTP server listen port |\n| `DB_PATH` | `entitlements.db` | SQLite database file path |\n| `CARRIER_URL` | `http://localhost:8081` | Carrier API base URL |\n\nNo config files required. Set env vars before starting the server or in `docker-compose.yml`.\n\n```bash\n# Example\nexport PORT=9090\nexport DB_PATH=/data/prod.db\nexport CARRIER_URL=https://carrier.example.com\n./subscription-reconciler\n```\n\n## Testing\n\n### Run All Tests\n\n```bash\nmake test\n```\n\nThis runs all tests with the Go race detector enabled and generates a coverage profile.\n\n### Test Coverage\n\nOverall coverage: **100.0%** across 6 testable packages (plus integration tests in `tests/integration/`).\n\n| Package | Coverage | Description |\n|---------|----------|-------------|\n| `internal/domain/` | 100.0% | Business logic — state machine, resolution, event application |\n| `internal/adapter/httphandler/` | 100.0% | HTTP handlers — endpoint tests, validation, response formats |\n| `internal/service/` | 100.0% | Reconciler service — event processing, entitlement updates |\n| `internal/adapter/carrierhttp/` | 100.0% | Carrier client — HTTP integration with mock server |\n| `internal/adapter/sqlite/` | 100.0% | Repository — CRUD operations, transaction handling |\n| `internal/middleware/` | 100.0% | Middleware — rate limiting, body size limit, CORS, logging |\n\n### Test Categories\n\n**Unit tests** — Domain layer has 100% coverage testing all state transitions, edge cases, and resolution priority:\n\n```bash\ngo test ./internal/domain/... -v -race -cover\n```\n\n**Integration tests** — HTTP-to-SQLite tests exercise the full stack from HTTP request through handler, service, repository, and database:\n\n```bash\ngo test ./internal/adapter/httphandler/... -v -race -cover\n```\n\n**Concurrent tests** — Verify idempotent webhook processing under parallel requests:\n\n```bash\n# Tests send duplicate events concurrently and verify exactly one is processed\ngo test ./... -run TestConcurrent -v -race\n```\n\n**Out-of-order tests** — Verify events with older timestamps don't downgrade newer entitlements:\n\n```bash\ngo test ./... -run TestOutOfOrder -v -race\n```\n\n### Coverage Report\n\n```bash\ngo test ./... -coverprofile=coverage.out -race\ngo tool cover -html=coverage.out -o coverage.html\n```\n\n## Deployment\n\n### Docker Compose\n\nThe `docker-compose.yml` includes two services:\n\n```yaml\nservices:\n  reconciler:      # Main subscription reconciler on :8080\n  mock-carrier:    # Mock carrier API on :8081 for development\n```\n\n```bash\n# Build and start\ndocker compose up --build -d\n\n# View logs\ndocker compose logs -f reconciler\n\n# Stop\ndocker compose down\n```\n\n### Dockerfile\n\nMulti-stage build producing a minimal Alpine image:\n\n```dockerfile\n# Stage 1: Build (Go 1.26.2, CGO_ENABLED=0)\n# Stage 2: Runtime (alpine:3.20, minimal footprint)\n```\n\nThe binary is statically compiled with `CGO_ENABLED=0` using the pure-Go SQLite driver, so no C libraries are needed at runtime.\n\n### Health Checks\n\nDocker Compose includes built-in health checks:\n\n```yaml\nhealthcheck:\n  test: [\"CMD\", \"wget\", \"--spider\", \"-q\", \"http://localhost:8080/health\"]\n  interval: 30s\n  timeout: 5s\n  retries: 3\n```\n\n### Graceful Shutdown\n\nThe server handles `SIGINT`/`SIGTERM` for graceful shutdown:\n\n1. Stops accepting new connections\n2. Waits up to 10 seconds for in-flight requests\n3. Cancels all background worker goroutines via context\n4. Closes database connection\n\n## Tech Stack\n\n| Component | Technology | Version |\n|-----------|-----------|---------|\n| Language | Go | 1.26.2 |\n| Router | chi | v5.3.0 |\n| Database | SQLite (pure Go) | modernc.org/sqlite v1.50.1 |\n| Testing | testing + testify | v1.11.1 |\n| Container | Docker (Alpine) | 3.20 |\n| Logging | slog (stdlib) | Structured JSON |\n| Architecture | Hexagonal (ports \u0026 adapters) | — |\n\n### Project Structure\n\n```\n.\n├── cmd/\n│   ├── server/main.go              # Entry point, wiring, migrations\n│   └── mockcarrier/main.go         # Mock carrier API server\n├── internal/\n│   ├── domain/\n│   │   ├── entitlement.go          # Entitlement entity, state machine, resolution\n│   │   ├── product.go              # Product catalog with durations\n│   │   ├── audit.go                # Audit entry model\n│   │   └── notification.go         # Notification scheduling logic\n│   ├── port/\n│   │   ├── repository.go           # All repository interfaces (ports)\n│   │   └── carrier.go              # Carrier client interface\n│   ├── service/\n│   │   ├── reconciler.go           # Reconciler use-case orchestration\n│   │   ├── poller.go               # Carrier polling worker\n│   │   └── notifier.go             # Notification dispatch + scheduling\n│   ├── adapter/\n│   │   ├── httphandler/\n│   │   │   └── handler.go          # HTTP routes + handlers\n│   │   ├── sqlite/\n│   │   │   ├── sqlite.go           # Package declaration\n│   │   │   ├── db.go               # Common DB helpers\n│   │   │   ├── tx.go               # Transaction provider\n│   │   │   ├── entitlement.go      # Entitlement CRUD\n│   │   │   ├── store_event.go      # Store event dedup + insert\n│   │   │   ├── notification.go     # Notification scheduling + dispatch\n│   │   │   ├── carrier_poll.go     # Carrier poll logging + locks\n│   │   │   ├── audit_log.go        # Audit log insert + queries\n│   │   │   └── helpers.go          # Shared query helpers\n│   │   └── carrierhttp/\n│   │       └── client.go           # Carrier API HTTP client\n│   └── middleware/\n│       ├── ratelimit.go            # Per-IP rate limiter (100 req/min)\n│       ├── body_size.go            # Request body size limit (1MB)\n│       ├── cors.go                 # Custom CORS middleware\n│       └── logging.go              # Structured request logger\n├── tests/\n│   └── integration/\n│       └── integration_test.go     # Full-stack HTTP→SQLite tests\n├── migrations/\n│   ├── 001_create_tables.up.sql    # Reference SQL (not used at runtime)\n│   └── 001_create_tables.down.sql  # Reference SQL (not used at runtime)\n├── docs/                             # Local reference only (not tracked)\n├── Dockerfile                       # Multi-stage Alpine build\n├── Dockerfile.mock                  # Mock carrier API server\n├── docker-compose.yml               # Reconciler + mock carrier\n├── Makefile                         # Build, test, run commands\n├── go.mod / go.sum                  # Dependencies\n└── README.md                        # This file\n```\n\n---\n\nBuilt with hexagonal architecture principles. Business logic in `domain/` has zero external dependencies.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsulthonzh%2Fsubscription-reconciler","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsulthonzh%2Fsubscription-reconciler","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsulthonzh%2Fsubscription-reconciler/lists"}