openapi: 3.0.3
info:
  title: DigitalReceipt API
  description: >
    Laravel API backend for the DigitalReceipt MVP (lodging receipts only).
    Every response is shaped through the standard envelope described below —
    this contract is the source of truth for the frontend track (F8) and
    should be updated first whenever an endpoint shape changes.
  version: "1.0.0"
servers:
  - url: http://localhost:8000/api
    description: Local development
  - url: "{baseUrl}/api"
    description: Configurable deployment target
    variables:
      baseUrl:
        default: http://localhost:8000

tags:
  - name: Auth
  - name: Business
  - name: User Preferences
  - name: Guests
  - name: Receipts
  - name: Payments

security:
  - bearerAuth: []

paths:
  /auth/register:
    post:
      tags: [Auth]
      summary: Register a new business + its first admin user
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [business_name, name, email, password, password_confirmation]
              properties:
                business_name: { type: string, maxLength: 255 }
                name: { type: string, maxLength: 255 }
                email: { type: string, format: email, maxLength: 255 }
                password: { type: string, format: password, minLength: 8 }
                password_confirmation: { type: string, format: password }
      responses:
        "201":
          description: Registered. A verification email was sent; no token is issued yet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MessageEnvelope" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /auth/verify-email:
    post:
      tags: [Auth]
      summary: Complete email verification using the token from the emailed link
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, email]
              properties:
                token: { type: string }
                email: { type: string, format: email }
      responses:
        "200":
          description: Email verified.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MessageEnvelope" }
        "422":
          description: Invalid/expired token (`INVALID_VERIFICATION_TOKEN`) or validation error.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  /auth/resend-verification:
    post:
      tags: [Auth]
      summary: Resend the verification email (generic response; doesn't reveal account state)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "200":
          description: Always the same generic message, regardless of whether the account exists or is already verified.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MessageEnvelope" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /auth/login:
    post:
      tags: [Auth]
      summary: Log in and receive a bearer token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string, format: password }
      responses:
        "200":
          description: Valid credentials.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          token: { type: string }
                          business: { $ref: "#/components/schemas/Business" }
        "401":
          description: Invalid credentials (`UNAUTHENTICATED`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "403":
          description: Account not yet verified (`EMAIL_NOT_VERIFIED`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /auth/logout:
    post:
      tags: [Auth]
      summary: Revoke the current bearer token
      responses:
        "200":
          description: Logged out.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MessageEnvelope" }
        "401": { $ref: "#/components/responses/Unauthenticated" }

  /auth/forgot-password:
    post:
      tags: [Auth]
      summary: Request a password-reset email (generic response; doesn't reveal account state)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "200":
          description: Always the same generic message, regardless of whether the email is registered.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MessageEnvelope" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /auth/reset-password:
    post:
      tags: [Auth]
      summary: Complete a password reset using the token from the emailed link
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, email, password, password_confirmation]
              properties:
                token: { type: string }
                email: { type: string, format: email }
                password: { type: string, format: password, minLength: 8 }
                password_confirmation: { type: string, format: password }
      responses:
        "200":
          description: Password reset; every existing token was revoked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MessageEnvelope" }
        "422":
          description: Invalid/expired token (`INVALID_RESET_TOKEN`) or validation error.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /auth/change-password:
    post:
      tags: [Auth]
      summary: Change your own password while logged in
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current_password, password, password_confirmation]
              properties:
                current_password: { type: string, format: password }
                password: { type: string, format: password, minLength: 8 }
                password_confirmation: { type: string, format: password }
      responses:
        "200":
          description: Password updated; every other session's token was revoked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MessageEnvelope" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /business:
    get:
      tags: [Business]
      summary: Fetch the authenticated business's profile
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Business" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
    put:
      tags: [Business]
      summary: Update the business profile
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 255 }
                phones: { type: array, maxItems: 5, items: { type: string, maxLength: 30 } }
                email: { type: string, format: email, nullable: true }
                website: { type: string, format: uri, nullable: true }
                footer_message: { type: string, maxLength: 1000, nullable: true }
                review_link: { type: string, format: uri, nullable: true }
                currency: { type: string, minLength: 3, maxLength: 3, example: XAF }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Business" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /business/logo:
    post:
      tags: [Business]
      summary: Upload/replace the business logo
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [logo]
              properties:
                logo: { type: string, format: binary, description: "Image file, max 2MB" }
      responses:
        "200":
          description: Logo saved.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Business" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "500": { $ref: "#/components/responses/ServerError" }

  /user/preferences:
    get:
      tags: [User Preferences]
      summary: Fetch your locale/theme preferences
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/UserPreferences" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
    put:
      tags: [User Preferences]
      summary: Update locale and/or theme
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                locale: { type: string, enum: [en, fr] }
                theme: { type: string, enum: [light, dark] }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/UserPreferences" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /guests:
    get:
      tags: [Guests]
      summary: Search/list guests belonging to the authenticated business
      parameters:
        - name: search
          in: query
          description: Partial match against name or phone
          schema: { type: string }
        - name: per_page
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
      responses:
        "200":
          description: Paginated list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Guest" }
                      meta: { $ref: "#/components/schemas/PaginationMeta" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
    post:
      tags: [Guests]
      summary: Create a guest
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 255 }
                phone: { type: string, maxLength: 30, nullable: true }
                email: { type: string, format: email, maxLength: 255, nullable: true }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Guest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /receipts:
    get:
      tags: [Receipts]
      summary: Search/filter/list receipts belonging to the authenticated business
      parameters:
        - name: search
          in: query
          description: Matches receipt_number or the guest's name
          schema: { type: string }
        - name: from
          in: query
          description: created_at >= this date (YYYY-MM-DD)
          schema: { type: string, format: date }
        - name: to
          in: query
          description: created_at <= this date (YYYY-MM-DD)
          schema: { type: string, format: date }
        - name: status
          in: query
          schema: { type: string, enum: [unpaid, partial, paid] }
        - name: per_page
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
      responses:
        "200":
          description: Paginated list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Receipt" }
                      meta: { $ref: "#/components/schemas/PaginationMeta" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "422":
          description: Unrecognized `status` value.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    post:
      tags: [Receipts]
      summary: Create a lodging receipt, optionally with an initial manual payment
      description: >
        Payments are always registered manually by staff — there is no
        payment gateway/processor integration in this MVP. `payment_method`
        is required only if `amount_paid` > 0.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [guest_id, check_in, check_out, total_due]
              properties:
                guest_id: { type: integer, description: "Must belong to the authenticated business" }
                check_in: { type: string, format: date }
                check_out: { type: string, format: date, description: "Must be after check_in" }
                total_due: { type: number, format: float, minimum: 0 }
                amount_paid: { type: number, format: float, minimum: 0, default: 0 }
                payment_method:
                  allOf:
                    - $ref: "#/components/schemas/PaymentMethod"
                  description: Required if amount_paid > 0
                notes: { type: string, maxLength: 2000, nullable: true }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Receipt" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /receipts/{receipt}:
    get:
      tags: [Receipts]
      summary: Fetch a single receipt with its payment history
      parameters:
        - $ref: "#/components/parameters/ReceiptId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Receipt" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404":
          description: Not found, including a receipt belonging to another business (never 403 — see README's "why 404 not 403").
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  /receipts/{receipt}/pdf:
    get:
      tags: [Receipts]
      summary: Stream the receipt as a PDF
      description: >
        Rendered in the requested (or the user's saved) locale and embeds a
        QR code linking to the business's review page when `review_link` is
        set — omitted gracefully (not an error) when it isn't. Rendered
        bytes are cached per `receipt_id` + a content fingerprint of the
        displayed fields + `locale`, so repeated requests for an unchanged
        receipt are served from cache; any change to the receipt or its
        payments (e.g. a new payment) changes the fingerprint and naturally
        busts the cache, so a stale PDF is never served.
      parameters:
        - $ref: "#/components/parameters/ReceiptId"
        - $ref: "#/components/parameters/PdfLocale"
      responses:
        "200":
          description: PDF stream.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404":
          description: Not found, including another business's receipt.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "422":
          description: Unrecognized `locale` value (`VALIDATION_ERROR`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  /receipts/{receipt}/send-email:
    post:
      tags: [Receipts]
      summary: Email the receipt PDF to the guest on file
      description: >
        Queued (`ShouldQueue`); runs on the `sync` connection in this MVP,
        so delivery still completes within the request today. The attached
        PDF and email body render in the requested (or the user's saved)
        locale, reusing the same render cache as the PDF endpoint above.
      parameters:
        - $ref: "#/components/parameters/ReceiptId"
        - $ref: "#/components/parameters/PdfLocale"
      responses:
        "200":
          description: Queued for delivery.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MessageEnvelope" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404":
          description: Not found, including another business's receipt.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "422":
          description: Guest has no email on file (`GUEST_EMAIL_MISSING`), or an unrecognized `locale` value (`VALIDATION_ERROR`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  /receipts/{receipt}/payments:
    post:
      tags: [Payments]
      summary: Record a further manual payment against a receipt
      description: >
        Manual registration only — cash/mobile_money/card/bank_transfer.
        No payment gateway/processor is integrated. Recalculates and
        persists the receipt's `amount_paid`/`balance`/`status` from the
        sum of all payments, inside a row-locked transaction for
        concurrent-payment safety.
      parameters:
        - $ref: "#/components/parameters/ReceiptId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, method]
              properties:
                amount: { type: number, format: float, minimum: 0.01 }
                method: { $ref: "#/components/schemas/PaymentMethod" }
                paid_at: { type: string, format: date-time, description: "Defaults to now()" }
      responses:
        "201":
          description: Payment recorded; returns the updated receipt.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Receipt" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404":
          description: Not found, including another business's receipt.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "422": { $ref: "#/components/responses/ValidationError" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "Laravel Sanctum personal access token, e.g. `Authorization: Bearer 1|abc123...`"

  parameters:
    ReceiptId:
      name: receipt
      in: path
      required: true
      schema: { type: integer }

    PdfLocale:
      name: locale
      in: query
      required: false
      description: Overrides the authenticated user's saved locale for this request only. Defaults to the user's saved `locale` preference when omitted.
      schema: { type: string, enum: [en, fr] }

  responses:
    Unauthenticated:
      description: Missing/invalid bearer token (`UNAUTHENTICATED`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    ValidationError:
      description: Validation failed (`VALIDATION_ERROR`), field errors in `error.details`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    RateLimited:
      description: Too many requests (`RATE_LIMITED`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    ServerError:
      description: Unexpected server error (`SERVER_ERROR`) — generic message only, no stack trace.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  schemas:
    SuccessEnvelope:
      type: object
      properties:
        success: { type: boolean, example: true }
        data: {}
        meta: { type: object, nullable: true }
      required: [success, data]

    MessageEnvelope:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                message: { type: string }

    ErrorEnvelope:
      type: object
      properties:
        success: { type: boolean, example: false }
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - VALIDATION_ERROR
                - UNAUTHENTICATED
                - UNAUTHORIZED
                - NOT_FOUND
                - RATE_LIMITED
                - INVALID_RESET_TOKEN
                - EMAIL_NOT_VERIFIED
                - INVALID_VERIFICATION_TOKEN
                - GUEST_EMAIL_MISSING
                - SERVER_ERROR
            message: { type: string }
            details: { type: object, nullable: true, description: "Field-level validation errors, when applicable" }

    PaginationMeta:
      type: object
      properties:
        current_page: { type: integer }
        per_page: { type: integer }
        total: { type: integer }
        last_page: { type: integer }

    PaymentMethod:
      type: string
      enum: [cash, mobile_money, card, bank_transfer]
      description: Manual entry only — no payment gateway/processor is integrated in this MVP.

    ReceiptStatus:
      type: string
      enum: [unpaid, partial, paid]

    Business:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        phones: { type: array, items: { type: string } }
        email: { type: string, nullable: true }
        website: { type: string, nullable: true }
        logo_url: { type: string, nullable: true }
        footer_message: { type: string, nullable: true }
        review_link: { type: string, nullable: true }
        currency: { type: string, example: XAF }

    UserPreferences:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        email: { type: string }
        locale: { type: string, enum: [en, fr] }
        theme: { type: string, enum: [light, dark] }

    Guest:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        phone: { type: string, nullable: true }
        email: { type: string, nullable: true }
        created_at: { type: string, format: date-time }

    Payment:
      type: object
      properties:
        id: { type: integer }
        amount: { type: number, format: float }
        method: { $ref: "#/components/schemas/PaymentMethod" }
        paid_at: { type: string, format: date-time }

    Receipt:
      type: object
      properties:
        id: { type: integer }
        receipt_number: { type: string, example: "000042" }
        guest: { $ref: "#/components/schemas/Guest" }
        check_in: { type: string, format: date }
        check_out: { type: string, format: date }
        total_due: { type: number, format: float }
        amount_paid: { type: number, format: float }
        balance: { type: number, format: float, description: "total_due − amount_paid; negative represents credit/overpayment due back" }
        status: { $ref: "#/components/schemas/ReceiptStatus" }
        currency: { type: string }
        notes: { type: string, nullable: true }
        payments:
          type: array
          items: { $ref: "#/components/schemas/Payment" }
        created_at: { type: string, format: date-time }
