openapi: 3.0.0
info:
    version: '0.0'
    title: Qomon
    description: "This is Qomon's API definition.\n\nTo get an authorization go to [Qomon's setting page](https://qomon.app/settings/extensions/connect) and create an API key."
servers:
    -
        description: 'Qomon local'
        url: 'http://localhost:8080/api'
    -
        description: 'Qomon integration'
        url: 'https://test.quorumapps.com/api'
    -
        description: 'Qomon production'
        url: 'https://qomon.app/api'
tags:
    -
        name: Stripe
        description: 'Stripe payment integration: account connection/onboarding, price sync, and sync-loop/resync control. Endpoints tagged "[Backend only]" require an internal ultradmin token and are not for customer/admin self-service use.'
paths:
    /stripe/accounts:
        get:
            summary: 'List all Stripe accounts connected to the group'
            description: "Returns all Stripe accounts connected to the authenticated group.\nA group can have multiple accounts (e.g. one for a C3 org and one for a C4 org),\neach identified by a unique `url` label.\n"
            tags: [Stripe]
            security: [{OAuth2: []}]
            responses: {'200': {description: 'List of connected Stripe accounts.', content: {application/json: {schema: {type: array, items: {type: object, description: 'A single Stripe account connected to the group.', properties: {id: {type: integer, description: 'Internal record ID.'}, url: {type: string, description: 'The account label used to target this account in API calls (e.g. "stripe", "stripe_c3").', example: stripe_c3}, onboarding_status: {$ref: '#/components/schemas/StripeConnectStatus'}, email: {type: string, nullable: true, description: 'Email address associated with the Stripe account.'}}}}}}}, '401': {description: Unauthorized}, '500': {description: 'Internal server error.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}}
    /stripe/account:
        get:
            summary: 'Retrieve the Stripe account status for a group'
            description: "Returns the Stripe account ID and onboarding status for the given group.\nIf no Stripe account exists, it returns \"not_started\".\n\nUse `?url=` to target a specific account when the group has multiple Stripe accounts connected.\nDefaults to `\"stripe\"` (the primary account) when omitted.\n"
            tags: [Stripe]
            security: [{OAuth2: []}]
            parameters: [{name: url, in: query, required: false, schema: {type: string, default: stripe}, description: "Label identifying which Stripe account to target. A group can have multiple Stripe accounts\nconnected (e.g. `stripe`, `stripe_c3`, `stripe_c4`). Defaults to `\"stripe\"` when omitted.\n"}]
            responses: {'200': {description: 'Stripe account details retrieved successfully.', content: {application/json: {schema: {type: object, properties: {account_id: {type: string, nullable: true, description: 'The Stripe account ID for the group (if it exists).'}, status: {$ref: '#/components/schemas/StripeConnectStatus'}}}}}}, '400': {description: 'Bad Request - Stripe account information is required.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}, '401': {description: Unauthorized}, '500': {description: 'Internal server error - Could not retrieve Stripe record from database.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}, '502': {description: 'Bad Gateway - Error fetching Stripe account from Stripe.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}}
    /stripe/connect:
        post:
            summary: 'Initiate or continue Stripe Connect onboarding'
            description: "Creates a Stripe account (if not existing for the given `url` label) or provides a link\nfor onboarding or updating details.\n\nTo connect a second Stripe account to the same group, pass a different `?url=` value\n(e.g. `?url=stripe_c3`). Defaults to `\"stripe\"` when omitted.\n"
            tags: [Stripe]
            security: [{OAuth2: []}]
            parameters: [{name: url, in: query, required: false, schema: {type: string, default: stripe}, description: "Label identifying which Stripe account to target. A group can have multiple Stripe accounts\nconnected (e.g. `stripe`, `stripe_c3`, `stripe_c4`). Defaults to `\"stripe\"` when omitted.\n"}]
            responses: {'200': {description: 'Stripe Connect status and onboarding link (if applicable).', content: {application/json: {schema: {type: object, properties: {status: {$ref: '#/components/schemas/StripeConnectStatus'}, link: {type: string, nullable: true, description: 'The URL for completing the Stripe onboarding process (if applicable).'}}}}}}, '400': {description: 'Bad Request - Stripe account information is required.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}, '401': {description: Unauthorized}, '409': {description: 'Conflict - Stripe account already exists for this group and url.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}, '500': {description: 'Internal server error - Could not save Stripe account to database.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}, '502': {description: 'Bad Gateway - Error interacting with Stripe API.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}}
    /stripe/disconnect:
        delete:
            summary: 'Disconnect a Stripe account'
            description: "Deauthorizes and removes the Stripe account for the group.\n\nPass `?url=` to disconnect a specific account (e.g. `?url=stripe_c3`).\n**When `url` is omitted, all Stripe accounts for the group are disconnected.**\n"
            tags: [Stripe]
            security: [{OAuth2: ['settings.connect:read:write:delete']}]
            parameters: [{name: url, in: query, required: false, schema: {type: string, default: stripe}, description: "Label identifying which Stripe account to target. A group can have multiple Stripe accounts\nconnected (e.g. `stripe`, `stripe_c3`, `stripe_c4`). Defaults to `\"stripe\"` when omitted.\n"}]
            responses: {'200': {description: 'Stripe account disconnected successfully.', content: {application/json: {schema: {type: object, properties: {status: {$ref: '#/components/schemas/StripeDisconnectStatus'}}}}}}, '400': {description: 'Bad Request - Stripe account information is required.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}, '404': {description: 'Not Found - No Stripe account found for the specified group.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}, '500': {description: 'Internal Server Error - Could not remove Stripe records from the database.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}, '502': {description: 'Bad Gateway - Failed to deauthorize Stripe account from Stripe API.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}}
    /stripe/oauth:
        get:
            summary: 'Generate Stripe OAuth authorization URL'
            description: "Generates the Stripe OAuth authorization URL. Redirect the user to the returned `link`\nto let them connect their existing Stripe account or create a new one.\n\nPass `?url=` to name the account being connected (e.g. `?url=stripe_c3`).\nDefaults to `\"stripe\"` when omitted.\n"
            tags: [Stripe]
            security: [{OAuth2: []}]
            parameters: [{name: url, in: query, required: false, schema: {type: string, default: stripe}, description: "Label identifying which Stripe account to target. A group can have multiple Stripe accounts\nconnected (e.g. `stripe`, `stripe_c3`, `stripe_c4`). Defaults to `\"stripe\"` when omitted.\n"}]
            responses: {'200': {description: 'Stripe OAuth authorization URL generated successfully.', content: {application/json: {schema: {type: object, properties: {status: {$ref: '#/components/schemas/StripeConnectStatus'}, link: {type: string, nullable: true, description: 'The URL for completing the Stripe onboarding process (if applicable).'}}}}}}, '400': {description: 'Bad Request - Stripe account information is required.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}, '409': {description: 'Conflict - Stripe account already exists for this group and url.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}, '500': {description: 'Internal Server Error - Error generating the authorization URL.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}}
    /stripe/start:
        get:
            summary: '[Backend only] Start the Stripe sync loop for every connected group'
            x-audience: backend-only
            description: "**Backend only — not for customer/admin self-service use.** Requires an internal\nultradmin token.\n\nStarts the background sync loop for every Stripe account across every group.\nUsed after a deploy/incident, since nothing restarts sync loops automatically.\nTo start a single account instead, use `POST /stripe/start-sync` (admin).\n"
            tags: [Stripe]
            security: [{OAuth2: []}]
            responses: {'200': {description: 'Sync loops started.'}, '401': {description: Unauthorized}, '500': {description: 'Internal server error.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}}
    /stripe/stop:
        get:
            summary: '[Backend only] Stop the Stripe sync loop for a group'
            x-audience: backend-only
            description: "**Backend only — not for customer/admin self-service use.** Requires an internal\nultradmin token.\n\nStops the background sync loop for the group.\n\nPass `?url=` to stop the loop for a specific Stripe account.\nDefaults to `\"stripe\"` when omitted.\n"
            tags: [Stripe]
            security: [{OAuth2: []}]
            parameters: [{name: url, in: query, required: false, schema: {type: string, default: stripe}, description: "Label identifying which Stripe account to target. A group can have multiple Stripe accounts\nconnected (e.g. `stripe`, `stripe_c3`, `stripe_c4`). Defaults to `\"stripe\"` when omitted.\n"}]
            responses: {'200': {description: 'Sync loop stopped.'}, '401': {description: Unauthorized}, '404': {description: 'Not Found - No Stripe config for this group.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}, '500': {description: 'Internal server error.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}}
    /stripe/resync:
        get:
            summary: '[Backend only] Force-resync one, many, or all Stripe accounts'
            x-audience: backend-only
            description: "**Backend only — not for customer/admin self-service use.** Requires an internal\nultradmin token. This is the rare-case \"big hammer\" tool; the self-service\nequivalent for a group's own admin is `POST /stripe/resync` below.\n\nRe-fetches charges/subscriptions in `[from,to]`, resets the account's persisted\n`last_update` cursor, and restarts its live sync loop. Scope depends on which\nparams are given:\n- `group_id` omitted -> every group's every Stripe account\n- `group_id` given, `url` omitted -> every Stripe account of that group\n- `group_id` and `url` given -> that single account\n- `price_id` given -> only payments referencing that Stripe price are resynced\n"
            tags: [Stripe]
            security: [{OAuth2: []}]
            parameters: [{name: group_id, in: query, required: false, schema: {type: integer}, description: "Backend only. Group to target. Omitted -> every group.\n"}, {name: url, in: query, required: false, schema: {type: string, default: stripe}, description: "Label identifying which Stripe account to target. A group can have multiple Stripe accounts\nconnected (e.g. `stripe`, `stripe_c3`, `stripe_c4`). Defaults to `\"stripe\"` when omitted.\n"}, {name: from, in: query, required: false, schema: {type: string, format: date}, description: "Start of the resync window, RFC3339 or `YYYY-MM-DD`. Defaults to `2000-01-01` (effectively\nfull history — Stripe didn't exist before 2011) when omitted.\n"}, {name: to, in: query, required: false, schema: {type: string, format: date}, description: "End of the resync window, RFC3339 or `YYYY-MM-DD`. Defaults to now when omitted.\n"}, {name: price_id, in: query, required: false, schema: {type: string}, description: "Scopes the resync to only payments referencing this Stripe price ID (checked via\n`qomon_price_id`/`price_id` metadata for one-time charges, or the subscription's item\nprice for recurring ones). Omit to resync every payment in the window. Typical use:\nright after adding a new DonationPrice/MembershipPrice, to catch payments that\nreferenced it before it existed in Qomon.\n"}]
            responses: {'200': {description: 'Resync started for the resolved account(s).', content: {application/json: {schema: {type: array, items: {type: object, description: 'The Stripe account record acted on by this call.', properties: {group_id: {type: integer}, url: {type: string, example: stripe_c3}, status: {type: string, description: 'Current sync loop status, when applicable ("ongoing" / "stopped" / "error").'}}}}}}}, '400': {description: 'Bad Request - invalid group_id, invalid date, or from after to.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}, '401': {description: Unauthorized}, '500': {description: 'Internal server error - could not resolve target account(s).', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}}
        post:
            summary: 'Resync your group''s Stripe account for a date range'
            description: "Self-service resync for the authenticated group's own Stripe account\n(identified by `?url=`). Unlike the backend-only `GET /stripe/resync`, this\n**never** touches the live sync loop or its `last_update` cursor — it's a\none-off, bounded catch-up pass safe to run alongside normal syncing. Useful\nafter fixing Stripe metadata (e.g. `qomon_price_id`) on old payments, to pull\nin payments that never synced, or — via `?price_id=` — to catch payments that\nreferenced a price added to Qomon only after they were made.\n\nOnly one resync may run at a time per account: calling this again while one\nis already in flight returns `409`. If the service restarts mid-run, the next\ncall automatically resumes from a checkpoint instead of starting over.\n"
            tags: [Stripe]
            security: [{OAuth2: []}]
            parameters: [{name: url, in: query, required: true, schema: {type: string}, description: "Label identifying which Stripe account to target. Required — a group can have multiple\nStripe accounts connected (e.g. `stripe`, `stripe_c3`, `stripe_c4`).\n"}, {name: from, in: query, required: false, schema: {type: string, format: date}, description: "Start of the resync window, RFC3339 or `YYYY-MM-DD`. Defaults to `2000-01-01` (effectively\nfull history — Stripe didn't exist before 2011) when omitted.\n"}, {name: to, in: query, required: false, schema: {type: string, format: date}, description: "End of the resync window, RFC3339 or `YYYY-MM-DD`. Defaults to now when omitted.\n"}, {name: price_id, in: query, required: false, schema: {type: string}, description: "Scopes the resync to only payments referencing this Stripe price ID (checked via\n`qomon_price_id`/`price_id` metadata for one-time charges, or the subscription's item\nprice for recurring ones). Omit to resync every payment in the window. Typical use:\nright after adding a new DonationPrice/MembershipPrice, to catch payments that\nreferenced it before it existed in Qomon.\n"}]
            responses: {'200': {description: 'Resync started (or resumed) for the account.', content: {application/json: {schema: {type: object, description: 'The Stripe account record acted on by this call.', properties: {group_id: {type: integer}, url: {type: string, example: stripe_c3}, status: {type: string, description: 'Current sync loop status, when applicable ("ongoing" / "stopped" / "error").'}}}}}}, '400': {description: 'Bad Request - url is required, invalid date, or from after to.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}, '401': {description: Unauthorized}, '404': {description: 'Not Found - No Stripe account found for this group/url.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}, '409': {description: 'Conflict - a resync is already running for this account.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}, '500': {description: 'Internal server error.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}}
    /stripe/start-sync:
        post:
            summary: 'Start the sync loop once for your group''s Stripe account'
            description: "Starts the background sync loop for the authenticated group's Stripe account\n(identified by `?url=`), if it isn't already running. No-op if it is —\nsafe to call repeatedly, e.g. right after completing Stripe Connect\n(nothing starts the loop automatically today).\n"
            tags: [Stripe]
            security: [{OAuth2: []}]
            parameters: [{name: url, in: query, required: true, schema: {type: string}, description: "Label identifying which Stripe account to target. Required — a group can have multiple\nStripe accounts connected (e.g. `stripe`, `stripe_c3`, `stripe_c4`).\n"}]
            responses: {'200': {description: 'Loop already running, or just started.', content: {application/json: {schema: {type: object, description: 'The Stripe account record acted on by this call.', properties: {group_id: {type: integer}, url: {type: string, example: stripe_c3}, status: {type: string, description: 'Current sync loop status, when applicable ("ongoing" / "stopped" / "error").'}}}}}}, '400': {description: 'Bad Request - url is required.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}, '401': {description: Unauthorized}, '500': {description: 'Internal server error - could not read status or start the loop.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}}
    /stripe/sync-prices:
        post:
            summary: 'Sync prices from Stripe to the database'
            description: "Fetches all active prices from Stripe and syncs them to the database.\nRecurring prices become MembershipPrices; one-time prices become DonationPrices.\n\nPass `?url=` to sync prices for a specific Stripe account.\nDefaults to `\"stripe\"` when omitted.\n"
            tags: [Stripe]
            security: [{OAuth2: []}]
            parameters: [{name: url, in: query, required: false, schema: {type: string, default: stripe}, description: "Label identifying which Stripe account to target. A group can have multiple Stripe accounts\nconnected (e.g. `stripe`, `stripe_c3`, `stripe_c4`). Defaults to `\"stripe\"` when omitted.\n"}]
            responses: {'200': {description: 'Prices synced successfully.', content: {application/json: {schema: {type: object, properties: {membership_prices_created: {type: integer, description: 'Number of new MembershipPrice records created.'}, donation_prices_created: {type: integer, description: 'Number of new DonationPrice records created.'}, skipped: {type: integer, description: 'Number of prices already present, skipped.'}, archived_updated: {type: integer, description: 'Number of prices marked as archived/hidden.'}, errors: {type: array, nullable: true, items: {type: string}, description: 'Any non-fatal errors encountered during sync.'}}}}}}, '400': {description: 'Bad Request - Missing group or Stripe API key not configured.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}, '401': {description: Unauthorized}, '404': {description: 'Not Found - No Stripe account found for the group.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}, '500': {description: 'Internal server error.', content: {application/json: {schema: {type: object, properties: {error: {type: string, description: 'Error message describing the failure reason.'}}}}}}}
components:
    securitySchemes:
        OAuth2:
            type: oauth2
            flows: {clientCredentials: {tokenUrl: 'https://your-auth-provider.com/token', scopes: {'settings.connect:read:write:delete': 'Allows deleting a Stripe Connect account (only for users with Connect role)'}}}
    parameters:
        StripeUrl:
            name: url
            in: query
            required: false
            schema: {type: string, default: stripe}
            description: "Label identifying which Stripe account to target. A group can have multiple Stripe accounts\nconnected (e.g. `stripe`, `stripe_c3`, `stripe_c4`). Defaults to `\"stripe\"` when omitted.\n"
        StripeUrlRequired:
            name: url
            in: query
            required: true
            schema: {type: string}
            description: "Label identifying which Stripe account to target. Required — a group can have multiple\nStripe accounts connected (e.g. `stripe`, `stripe_c3`, `stripe_c4`).\n"
        StripeGroupId:
            name: group_id
            in: query
            required: false
            schema: {type: integer}
            description: "Backend only. Group to target. Omitted -> every group.\n"
        StripeFrom:
            name: from
            in: query
            required: false
            schema: {type: string, format: date}
            description: "Start of the resync window, RFC3339 or `YYYY-MM-DD`. Defaults to `2000-01-01` (effectively\nfull history — Stripe didn't exist before 2011) when omitted.\n"
        StripePriceId:
            name: price_id
            in: query
            required: false
            schema: {type: string}
            description: "Scopes the resync to only payments referencing this Stripe price ID (checked via\n`qomon_price_id`/`price_id` metadata for one-time charges, or the subscription's item\nprice for recurring ones). Omit to resync every payment in the window. Typical use:\nright after adding a new DonationPrice/MembershipPrice, to catch payments that\nreferenced it before it existed in Qomon.\n"
        StripeTo:
            name: to
            in: query
            required: false
            schema: {type: string, format: date}
            description: "End of the resync window, RFC3339 or `YYYY-MM-DD`. Defaults to now when omitted.\n"
    schemas:
        StripeConnectStatus:
            type: string
            enum: [not_started, pending_authorization, authorization_failed, update_needed, onboarding_completed, manual_onboarding, deleted, error, account_retrieval_failed, db_save_failed]
            description: 'Possible statuses of Stripe Connect onboarding.'
        StripeDisconnectStatus:
            type: string
            enum: [deleted, error]
            description: 'Possible statuses of disconnecting stripe.'
        StripeAccountStatus:
            type: string
            enum: [update_needed, onboarding_completed, manual_onboarding, not_started, error]
            description: 'Possible statuses of Stripe Account info.'
        StripeAccountItem:
            type: object
            description: 'A single Stripe account connected to the group.'
            properties: {id: {type: integer, description: 'Internal record ID.'}, url: {type: string, description: 'The account label used to target this account in API calls (e.g. "stripe", "stripe_c3").', example: stripe_c3}, onboarding_status: {$ref: '#/components/schemas/StripeConnectStatus'}, email: {type: string, nullable: true, description: 'Email address associated with the Stripe account.'}}
        StripeConnectReply:
            type: object
            properties: {status: {$ref: '#/components/schemas/StripeConnectStatus'}, link: {type: string, nullable: true, description: 'The URL for completing the Stripe onboarding process (if applicable).'}}
        StripeDisconnectReply:
            type: object
            properties: {status: {$ref: '#/components/schemas/StripeDisconnectStatus'}}
        StripeAccountReply:
            type: object
            properties: {account_id: {type: string, nullable: true, description: 'The Stripe account ID for the group (if it exists).'}, status: {$ref: '#/components/schemas/StripeConnectStatus'}}
        StripeSyncPricesReply:
            type: object
            properties: {membership_prices_created: {type: integer, description: 'Number of new MembershipPrice records created.'}, donation_prices_created: {type: integer, description: 'Number of new DonationPrice records created.'}, skipped: {type: integer, description: 'Number of prices already present, skipped.'}, archived_updated: {type: integer, description: 'Number of prices marked as archived/hidden.'}, errors: {type: array, nullable: true, items: {type: string}, description: 'Any non-fatal errors encountered during sync.'}}
        StripeAccountRef:
            type: object
            description: 'The Stripe account record acted on by this call.'
            properties: {group_id: {type: integer}, url: {type: string, example: stripe_c3}, status: {type: string, description: 'Current sync loop status, when applicable ("ongoing" / "stopped" / "error").'}}
        ErrorResponse:
            type: object
            properties: {error: {type: string, description: 'Error message describing the failure reason.'}}
