Implement Lemon Squeezy API client library
Six modules covering the full Lemon Squeezy API: - Core: client auth, JSON:API parsing helpers, pagination, filtering - Products & Variants: list and retrieve (read-only API) - Checkouts: create with custom data/options, list, retrieve - Orders & Subscriptions: CRUD plus subscription lifecycle management - Webhooks: HMAC-SHA256 verification, event parsing, endpoint CRUD - License Keys: main API management + unauthenticated License API for validate/activate/deactivate
JSON:API helpers handle data extraction, relationships, included resources, and pagination metadata. Generic ls-list-endpoint and ls-get-endpoint eliminate boilerplate across all resource modules.
44 tests covering record parsing, JSON:API helpers, webhook signature verification, and query parameter building.
README.md | 196 ++++++++++++++++++++++++++++++++++++++++++++-
src/sigil/lemonsqueezy.sgl | 375 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/lemonsqueezy/checkout.sgl | 119 ++++++++++++++++++++++++++++
src/sigil/lemonsqueezy/license.sgl | 252 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/lemonsqueezy/order.sgl | 273 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/lemonsqueezy/product.sgl | 147 ++++++++++++++++++++++++++++++++++
src/sigil/lemonsqueezy/webhook.sgl | 191 ++++++++++++++++++++++++++++++++++++++++++++
test/lemonsqueezy-test.sgl | 683 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 2235 insertions(+), 1 deletion(-)README.mdmodified
# sigil-lemonsqueezyLemon Squeezy API client library for Sigil No newline at end of fileLemon Squeezy API client library for Sigil. Provides functions for managingproducts, checkouts, orders, subscriptions, webhooks, and license keys viathe [Lemon Squeezy API](https://docs.lemonsqueezy.com/api).## Features- **JSON:API parsing** — Helpers for extracting data, attributes, relationships, and included resources from JSON:API responses- **Products & Variants** — List and retrieve products and their variants- **Checkouts** — Create checkout URLs with custom data, product options, and pricing overrides- **Orders & Order Items** — List and retrieve orders and line items- **Subscriptions** — List, retrieve, update, and cancel subscriptions- **Webhooks** — CRUD operations plus HMAC-SHA256 signature verification- **License Keys** — Manage keys via the main API; validate, activate, and deactivate via the unauthenticated License API- **Pagination** — Built-in support for page-based pagination and filtering## Modules| Module | Description ||--------|-------------|| `(sigil lemonsqueezy)` | Core client, auth, JSON:API helpers, pagination || `(sigil lemonsqueezy product)` | Products and variants || `(sigil lemonsqueezy checkout)` | Checkout creation and retrieval || `(sigil lemonsqueezy order)` | Orders, order items, subscriptions || `(sigil lemonsqueezy webhook)` | Webhook CRUD and signature verification || `(sigil lemonsqueezy license)` | License key management and validation |## Quick Start```scheme(import (sigil lemonsqueezy) (sigil lemonsqueezy product) (sigil lemonsqueezy order) (sigil lemonsqueezy checkout));; Create a client with your API key(define client (ls-client api-key: "your-api-key"));; List products(define products (ls-products client))(for-each (lambda (p) (display (ls-product-name p)) (display " - ") (display (ls-product-price-formatted p)) (newline)) products);; Get a single product(define product (ls-product-get client "123"));; Create a checkout with custom data(define checkout (ls-create-checkout client "1" "10" #{ checkout-data: #{ email: "[email protected]" custom: #{ user_id: "42" } } }))(display (ls-checkout-url checkout));; List orders with filtering(define orders (ls-orders client #{ filter: #{ store_id: "1" } }))```## WebhooksVerify incoming webhook signatures and parse event payloads:```scheme(import (sigil lemonsqueezy) (sigil lemonsqueezy webhook));; Verify the X-Signature header(if (ls-verify-webhook signing-secret raw-request-body x-signature-header) (let ((event (parse-webhook-event raw-request-body))) (let ((event-name (ls-webhook-event-name event)) (custom-data (ls-webhook-event-custom-data event))) ;; Dispatch by event name (cond ((string=? event-name "order_created") ;; Grant access using custom_data.user_id (grant-access (dict-ref custom-data user_id:))) ((string=? event-name "subscription_expired") ;; Revoke access (revoke-access (dict-ref custom-data user_id:)))))) (error "Invalid webhook signature"))```## License KeysThe License API endpoints (validate, activate, deactivate) do not requirean API key and are designed for use in client applications:```scheme(import (sigil lemonsqueezy license));; Validate a license key (no auth needed)(define result (ls-validate-license "38b1460a-5104-4067-a91d-77b872934d51"))(if (ls-license-validation-valid result) (display "License is valid!") (display "License is invalid"));; Activate a license key(define activation (ls-activate-license "38b1460a-5104-4067-a91d-77b872934d51" "mysite.com"));; Deactivate(ls-deactivate-license "38b1460a-5104-4067-a91d-77b872934d51" "instance-uuid")```## JSON:API HelpersLemon Squeezy uses JSON:API format. The core module provides helpers:```scheme;; Extract data from a response(jsonapi-data response) ; single resource(jsonapi-data-list response) ; list of resources(jsonapi-attr resource name:) ; get attribute(jsonapi-attrs resource) ; full attributes dict;; Relationships(jsonapi-relationship-id resource store:) ; single relationship ID(jsonapi-relationship-ids resource variants:) ; has-many relationship IDs;; Included resources(jsonapi-included response) ; all included(jsonapi-find-included response "variants" "10") ; find by type+id;; Pagination(jsonapi-pagination-meta response) ; => #{ current-page: 1 last-page: 3 ... }(ls-paginate (lambda (page) ...)) ; auto-paginate all pages```## Pagination and FilteringAll list endpoints accept an options dict:```scheme;; Paginate(ls-products client #{ page: 2 per-page: 50 });; Filter(ls-subscriptions client #{ filter: #{ status: "active" product_id: "1" } });; Include related resources(ls-products client #{ include: "variants,store" });; Auto-paginate all pages(ls-paginate (lambda (page) (ls-get/json client (string-append (ls-api-url client "v1" "products") (build-query-string (ls-list-params #{ page: page per-page: 100 }))))))```## BuildingRequires a C toolchain for native dependencies (sigil-crypto, sigil-tls).```bash# With local sigil checkoutsigil build --redirects dev-redirects.sgl# Run testssigil test```## Dependencies- sigil-stdlib (core, dict, string, struct, json)- sigil-http (HTTP client)- sigil-tls (HTTPS support)- sigil-json (JSON parsing)- sigil-crypto (HMAC-SHA256 for webhook verification)- sigil-log (logging)## API Coverage| Resource | Operations ||----------|-----------|| Products | list, get || Variants | list, get || Checkouts | create, get, list || Orders | list, get || Order Items | list, get || Subscriptions | list, get, update, cancel || Webhooks | create, get, list, update, delete, verify || License Keys | list, get, update (main API) || License API | validate, activate, deactivate (no auth) |## LicenseBSD-3-Clausesrc/sigil/lemonsqueezy.sgladded
;;; (sigil lemonsqueezy) - Lemon Squeezy API client library.;;;;;; Core module providing authentication, HTTP helpers, JSON:API parsing;;; utilities, pagination, and record types for the Lemon Squeezy API.;;;;;; Lemon Squeezy uses JSON:API format (jsonapi.org) for all responses.;;; This module provides helpers to extract data from that structure.(define-library (sigil lemonsqueezy) (import (sigil core) (sigil dict) (sigil string) (sigil struct) (sigil json) (sigil http client)) (export ;; Client ls-client ls-client? ls-client-api-key ls-client-base-url ;; Shared HTTP helpers (for sub-modules) ls-auth-headers ls-api-url ls-get/json ls-post/json ls-patch/json ls-delete/json ;; JSON:API helpers jsonapi-id jsonapi-type jsonapi-attr jsonapi-attrs jsonapi-relationship-id jsonapi-relationship-ids jsonapi-data jsonapi-data-list jsonapi-included jsonapi-find-included jsonapi-meta jsonapi-links jsonapi-pagination-meta maybe-null ;; Pagination ls-paginate ls-list-params ;; URL helpers url-encode-value build-query-string ;; Generic endpoint helpers ls-list-endpoint ls-get-endpoint ;; Error ls-error) (begin ;; --------------------------------------------------------------- ;; Records ;; --------------------------------------------------------------- (define-struct ls-client (api-key) (base-url default: "https://api.lemonsqueezy.com")) ;; --------------------------------------------------------------- ;; URL encoding helpers ;; --------------------------------------------------------------- ;;; URL-encode a query parameter value (RFC 3986 unreserved chars). (define (url-encode-value s) (let ((len (string-length s))) (let loop ((i 0) (acc '())) (if (>= i len) (list->string (reverse acc)) (let ((c (string-ref s i))) (cond ((or (char-alphabetic? c) (char-numeric? c) (char=? c #\-) (char=? c #\_) (char=? c #\.) (char=? c #\~)) (loop (+ i 1) (cons c acc))) ((char=? c #\space) (loop (+ i 1) (cons #\+ acc))) (else (let ((n (char->integer c))) (loop (+ i 1) (append (reverse (string->list (string-append "%" (if (< n 16) "0" "") (number->string n 16)))) acc)))))))))) ;;; Build a query string from a list of (key . value) pairs. ;;; Pairs with #f values are omitted. (define (build-query-string params) (let ((parts (filter (lambda (p) (cdr p)) params))) (if (null? parts) "" (string-append "?" (string-join (map (lambda (p) (string-append (url-encode-value (car p)) "=" (url-encode-value (cdr p)))) parts) "&"))))) ;; --------------------------------------------------------------- ;; Internal helpers ;; --------------------------------------------------------------- ;;; Build authorization headers for the Lemon Squeezy API. ;;; All requests require Accept and Content-Type for JSON:API. (define (ls-auth-headers client) #{ authorization: (string-append "Bearer " (ls-client-api-key client)) accept: "application/vnd.api+json" content-type: "application/vnd.api+json" }) ;;; Build an API URL from the client base URL and path segments. (define (ls-api-url client . parts) (apply string-append (ls-client-base-url client) (map (lambda (p) (string-append "/" p)) parts))) ;;; Normalize a JSON null value to #f. ;;; json-decode returns the symbol 'null' for JSON null, which is ;;; truthy in boolean context. This helper normalizes it. (define (maybe-null v) (if (or (not v) (eq? v 'null)) #f v)) ;;; Raise a Lemon Squeezy API error with context from the response. (define (ls-error status body) (let* ((parsed (if (and body (not (string=? body ""))) (guard (exn (else #f)) (json-decode body)) #f)) (errors (if (and parsed (dict? parsed)) (dict-ref parsed errors: #f) #f)) (detail (if (and errors (array? errors) (> (array-length errors) 0)) (let ((err (array-ref errors 0))) (dict-ref err detail: "Unknown error")) "Unknown error"))) (cond ((= status 401) (error (string-append "Lemon Squeezy API 401 Unauthorized. " "Check your API key. " detail))) ((= status 404) (error (string-append "Lemon Squeezy API 404 Not Found. " detail))) ((= status 422) (error (string-append "Lemon Squeezy API 422 Validation Error. " detail))) ((= status 429) (error (string-append "Lemon Squeezy API 429 Rate Limited. " "Retry after a delay."))) (else (error (string-append "Lemon Squeezy API error " (number->string status) ": " detail)))))) ;;; Check an HTTP response and return parsed JSON or raise an error. (define (check-ls-response response) (if (not (http-response? response)) (error "Lemon Squeezy API request failed: no response")) (let ((status (http-response-status response)) (body (http-response-body response))) (if (>= status 400) (ls-error status body) (if (and body (not (string=? body ""))) (json-decode body) #t)))) ;;; Authenticated JSON GET request. (define (ls-get/json client url) (check-ls-response (http-get url headers: (ls-auth-headers client)))) ;;; Authenticated JSON POST request. (define (ls-post/json client url body) (check-ls-response (http-post url (if (string? body) body (json-encode body)) headers: (ls-auth-headers client)))) ;;; Authenticated JSON PATCH request. (define (ls-patch/json client url body) (check-ls-response (http-patch url (if (string? body) body (json-encode body)) headers: (ls-auth-headers client)))) ;;; Authenticated JSON DELETE request. (define (ls-delete/json client url) (check-ls-response (http-delete url headers: (ls-auth-headers client)))) ;; --------------------------------------------------------------- ;; Generic endpoint helpers ;; --------------------------------------------------------------- ;;; Fetch a list from a JSON:API endpoint with optional filtering/pagination. ;;; endpoint: the API path segments (e.g., "v1" "products") ;;; parser: function to convert a JSON:API resource to a record ;;; opts: optional dict with page:, per-page:, filter:, include: keys (define (ls-list-endpoint client parser endpoint . rest) (let* ((opts (if (null? rest) #{} (car rest))) (params (ls-list-params opts)) (url (string-append (apply ls-api-url client endpoint) (build-query-string params))) (response (ls-get/json client url))) (map parser (jsonapi-data-list response)))) ;;; Fetch a single resource from a JSON:API endpoint by ID. ;;; endpoint: the API path segments (e.g., "v1" "products") ;;; id: the resource ID string ;;; parser: function to convert a JSON:API resource to a record (define (ls-get-endpoint client parser endpoint id) (let* ((url (apply ls-api-url client (append endpoint (list id)))) (response (ls-get/json client url))) (parser (jsonapi-data response)))) ;; --------------------------------------------------------------- ;; JSON:API helpers ;; --------------------------------------------------------------- ;;; Extract the id from a JSON:API resource object. (define (jsonapi-id resource) (dict-ref resource id:)) ;;; Extract the type from a JSON:API resource object. (define (jsonapi-type resource) (dict-ref resource type:)) ;;; Extract a single attribute from a JSON:API resource object. ;;; Returns default if the attribute is missing or null. (define (jsonapi-attr resource key . rest) (let* ((default (if (null? rest) #f (car rest))) (attrs (dict-ref resource attributes: #{})) (val (dict-ref attrs key default))) (maybe-null val))) ;;; Extract the full attributes dict from a JSON:API resource object. (define (jsonapi-attrs resource) (dict-ref resource attributes: #{})) ;;; Extract a single relationship ID from a JSON:API resource. ;;; Relationships are under data.relationships.<name>.data.id (define (jsonapi-relationship-id resource rel-name) (let* ((rels (dict-ref resource relationships: #{})) (rel (dict-ref rels rel-name #{})) (data (dict-ref rel data: #f))) (if (and data (dict? data)) (maybe-null (dict-ref data id: #f)) #f))) ;;; Extract relationship IDs for a has-many relationship. ;;; Returns a list of ID strings. (define (jsonapi-relationship-ids resource rel-name) (let* ((rels (dict-ref resource relationships: #{})) (rel (dict-ref rels rel-name #{})) (data (dict-ref rel data: #f))) (if (and data (array? data)) (map (lambda (d) (dict-ref d id:)) (array->list data)) '()))) ;;; Extract the data field from a JSON:API response. ;;; For single-resource responses, returns the resource object. (define (jsonapi-data response) (dict-ref response data:)) ;;; Extract data as a list from a JSON:API list response. ;;; For list endpoints, data is an array. (define (jsonapi-data-list response) (let ((data (dict-ref response data: #[]))) (if (array? data) (array->list data) (list data)))) ;;; Extract the included array from a JSON:API response. ;;; Returns a list of included resource objects. (define (jsonapi-included response) (let ((included (dict-ref response included: #f))) (if (and included (array? included)) (array->list included) '()))) ;;; Find an included resource by type and id. (define (jsonapi-find-included response type id) (let loop ((items (jsonapi-included response))) (cond ((null? items) #f) ((and (string=? (dict-ref (car items) type:) type) (equal? (dict-ref (car items) id:) id)) (car items)) (else (loop (cdr items)))))) ;;; Extract meta from a JSON:API response. (define (jsonapi-meta response) (dict-ref response meta: #{})) ;;; Extract links from a JSON:API response. (define (jsonapi-links response) (dict-ref response links: #{})) ;;; Extract pagination metadata from a JSON:API list response. ;;; Returns a dict with current-page, last-page, per-page, total, from, to. (define (jsonapi-pagination-meta response) (let ((meta (jsonapi-meta response))) (let ((page (dict-ref meta page: #{}))) #{ current-page: (dict-ref page currentPage: 1) last-page: (dict-ref page lastPage: 1) per-page: (dict-ref page perPage: 10) total: (dict-ref page total: 0) from: (dict-ref page from: #f) to: (dict-ref page to: #f) }))) ;; --------------------------------------------------------------- ;; Pagination & Filtering ;; --------------------------------------------------------------- ;;; Build query parameters for list endpoints. ;;; Takes an optional dict with page:, per-page:, filter:, and include: keys. ;;; filter: should be a dict of filter params (e.g., #{ store_id: "1" }). ;;; include: should be a comma-separated string (e.g., "variants,store"). (define (ls-list-params . rest) (let ((opts (if (null? rest) #{} (car rest)))) (let ((page (dict-ref opts page: #f)) (per-page (dict-ref opts per-page: #f)) (filters (dict-ref opts filter: #f)) (include (dict-ref opts include: #f))) (let ((base-params (list (cons "page[number]" (if page (number->string page) #f)) (cons "page[size]" (if per-page (number->string per-page) #f)) (cons "include" include)))) (if (and filters (dict? filters)) (append base-params (map (lambda (entry) (cons (string-append "filter[" (keyword->string (car entry)) "]") (if (number? (cdr entry)) (number->string (cdr entry)) (cdr entry)))) (dict-entries filters))) base-params))))) ;;; Fetch all pages from a paginated list endpoint. ;;; Calls fetcher with successive page numbers and accumulates results. ;;; fetcher should be a function taking a page number and returning ;;; a JSON:API response. ;;; Returns a list of all resource objects across all pages. (define (ls-paginate fetcher) (let loop ((page 1) (chunks '())) (let* ((response (fetcher page)) (items (jsonapi-data-list response)) (meta (jsonapi-pagination-meta response)) (current (dict-ref meta current-page:)) (last-page (dict-ref meta last-page:)) (chunks (cons items chunks))) (if (>= current last-page) (apply append (reverse chunks)) (loop (+ page 1) chunks))))) ))src/sigil/lemonsqueezy/checkout.sgladded
;;; (sigil lemonsqueezy checkout) - Checkout creation and retrieval.;;;;;; Checkouts create purchasable links for specific variants. This is the;;; primary way to initiate purchases programmatically. Custom data passed;;; through checkouts flows to webhooks via meta.custom_data.(define-library (sigil lemonsqueezy checkout) (import (sigil core) (sigil dict) (sigil string) (sigil struct) (sigil json) (sigil lemonsqueezy)) (export ;; Records ls-checkout ls-checkout? ls-checkout-id ls-checkout-url ls-checkout-store-id ls-checkout-variant-id ls-checkout-custom-price ls-checkout-expires-at ls-checkout-created-at ls-checkout-updated-at ls-checkout-test-mode ;; Parsing parse-checkout ;; API functions ls-create-checkout ls-checkout-get ls-checkouts) (begin ;; --------------------------------------------------------------- ;; Records ;; --------------------------------------------------------------- (define-struct ls-checkout (id) (url default: #f) (store-id default: #f) (variant-id default: #f) (custom-price default: #f) (expires-at default: #f) (created-at default: #f) (updated-at default: #f) (test-mode default: #f)) ;; --------------------------------------------------------------- ;; Parsing ;; --------------------------------------------------------------- ;;; Parse a JSON:API checkout resource into an ls-checkout record. (define (parse-checkout resource) (ls-checkout id: (jsonapi-id resource) url: (jsonapi-attr resource url: #f) store-id: (jsonapi-attr resource store_id: #f) variant-id: (jsonapi-attr resource variant_id: #f) custom-price: (jsonapi-attr resource custom_price: #f) expires-at: (jsonapi-attr resource expires_at: #f) created-at: (jsonapi-attr resource created_at: #f) updated-at: (jsonapi-attr resource updated_at: #f) test-mode: (jsonapi-attr resource test_mode: #f))) ;; --------------------------------------------------------------- ;; API functions ;; --------------------------------------------------------------- ;;; Create a checkout for a store and variant. ;;; ;;; store-id and variant-id are required (as strings). ;;; opts is a dict with optional keys: ;;; custom-price: — price in cents (integer) ;;; product-options: — dict of product display overrides ;;; checkout-options: — dict of checkout UI options ;;; checkout-data: — dict with email:, name:, custom:, discount_code:, etc. ;;; expires-at: — ISO 8601 expiration time ;;; preview: — boolean, include pricing breakdown ;;; ;;; Returns an ls-checkout record. (define (ls-create-checkout client store-id variant-id . rest) (let* ((opts (if (null? rest) #{} (car rest))) ;; Build attributes from optional fields (optional-fields (filter cdr (list (cons custom_price: (dict-ref opts custom-price: #f)) (cons product_options: (dict-ref opts product-options: #f)) (cons checkout_options: (dict-ref opts checkout-options: #f)) (cons checkout_data: (dict-ref opts checkout-data: #f)) (cons expires_at: (dict-ref opts expires-at: #f)) (cons preview: (dict-ref opts preview: #f))))) (attrs (apply dict (apply append (map (lambda (p) (list (car p) (cdr p))) optional-fields)))) (body #{ data: #{ type: "checkouts" attributes: attrs relationships: #{ store: #{ data: #{ type: "stores" id: store-id } } variant: #{ data: #{ type: "variants" id: variant-id } } } } }) (url (ls-api-url client "v1" "checkouts")) (response (ls-post/json client url body))) (parse-checkout (jsonapi-data response)))) ;;; Get a single checkout by ID. (define (ls-checkout-get client checkout-id) (ls-get-endpoint client parse-checkout '("v1" "checkouts") checkout-id)) ;;; List checkouts with optional filtering and pagination. (define (ls-checkouts client . rest) (apply ls-list-endpoint client parse-checkout '("v1" "checkouts") rest)) ))src/sigil/lemonsqueezy/license.sgladded
;;; (sigil lemonsqueezy license) - License key management.;;;;;; Two separate APIs:;;; 1. Main API (requires Bearer auth) — list/retrieve/update license keys;;; 2. License API (no auth needed) — validate/activate/deactivate keys;;;;;; The License API is designed for client applications and uses different;;; headers (application/json, application/x-www-form-urlencoded).(define-library (sigil lemonsqueezy license) (import (sigil core) (sigil dict) (sigil string) (sigil struct) (sigil json) (sigil http client) (sigil lemonsqueezy)) (export ;; Records ls-license-key ls-license-key? ls-license-key-id ls-license-key-store-id ls-license-key-customer-id ls-license-key-order-id ls-license-key-product-id ls-license-key-key ls-license-key-key-short ls-license-key-activation-limit ls-license-key-instances-count ls-license-key-disabled ls-license-key-status ls-license-key-expires-at ls-license-key-created-at ls-license-key-updated-at ls-license-validation ls-license-validation? ls-license-validation-valid ls-license-validation-error ls-license-validation-license-key ls-license-validation-instance ls-license-validation-meta ls-license-activation ls-license-activation? ls-license-activation-activated ls-license-activation-error ls-license-activation-license-key ls-license-activation-instance ls-license-activation-meta ;; Parsing parse-license-key parse-license-validation parse-license-activation ;; Main API functions (requires auth) ls-license-keys ls-license-key-get ls-license-key-update ;; License API functions (no auth required) ls-validate-license ls-activate-license ls-deactivate-license) (begin ;; --------------------------------------------------------------- ;; Records ;; --------------------------------------------------------------- (define-struct ls-license-key (id) (store-id default: #f) (customer-id default: #f) (order-id default: #f) (product-id default: #f) (key default: "") (key-short default: "") (activation-limit default: 0) (instances-count default: 0) (disabled default: #f) (status default: "inactive") (expires-at default: #f) (created-at default: #f) (updated-at default: #f)) (define-struct ls-license-validation (valid default: #f) (error default: #f) (license-key default: #{}) (instance default: #f) (meta default: #{})) (define-struct ls-license-activation (activated default: #f) (error default: #f) (license-key default: #{}) (instance default: #f) (meta default: #{})) ;; --------------------------------------------------------------- ;; Parsing ;; --------------------------------------------------------------- ;;; Parse a JSON:API license key resource into an ls-license-key record. (define (parse-license-key resource) (ls-license-key id: (jsonapi-id resource) store-id: (jsonapi-attr resource store_id: #f) customer-id: (jsonapi-attr resource customer_id: #f) order-id: (jsonapi-attr resource order_id: #f) product-id: (jsonapi-attr resource product_id: #f) key: (jsonapi-attr resource key: "") key-short: (jsonapi-attr resource key_short: "") activation-limit: (jsonapi-attr resource activation_limit: 0) instances-count: (jsonapi-attr resource instances_count: 0) disabled: (jsonapi-attr resource disabled: #f) status: (jsonapi-attr resource status: "inactive") expires-at: (jsonapi-attr resource expires_at: #f) created-at: (jsonapi-attr resource created_at: #f) updated-at: (jsonapi-attr resource updated_at: #f))) ;;; Normalize a dict field: null → empty dict, missing → empty dict. (define (dict-or-empty data key) (let ((v (dict-ref data key #f))) (if (or (not v) (eq? v 'null)) #{} v))) ;;; Parse a License API validation response. (define (parse-license-validation data) (ls-license-validation valid: (dict-ref data valid: #f) error: (maybe-null (dict-ref data error: #f)) license-key: (dict-or-empty data license_key:) instance: (maybe-null (dict-ref data instance: #f)) meta: (dict-or-empty data meta:))) ;;; Parse a License API activation/deactivation response. (define (parse-license-activation data) (ls-license-activation activated: (dict-ref data activated: #f) error: (maybe-null (dict-ref data error: #f)) license-key: (dict-or-empty data license_key:) instance: (maybe-null (dict-ref data instance: #f)) meta: (dict-or-empty data meta:))) ;; --------------------------------------------------------------- ;; License API helpers (no Bearer auth) ;; --------------------------------------------------------------- ;;; The License API uses different headers than the main API. (define license-api-headers #{ accept: "application/json" content-type: "application/x-www-form-urlencoded" }) ;;; License API base URL (hardcoded; does not use client base-url ;;; since these endpoints require no authentication). (define license-api-base "https://api.lemonsqueezy.com") ;;; Build a form-urlencoded body from an alist of (key . value) pairs. (define (form-encode-body params) (string-join (map (lambda (p) (string-append (url-encode-value (car p)) "=" (url-encode-value (cdr p)))) (filter (lambda (p) (cdr p)) params)) "&")) ;;; Make a POST request to the License API (form-urlencoded, no auth). (define (license-post url params) (let* ((body (form-encode-body params)) (response (http-post url body headers: license-api-headers))) (if (not (http-response? response)) (error "License API request failed: no response")) (let ((status (http-response-status response)) (resp-body (http-response-body response))) (if (and resp-body (not (string=? resp-body ""))) (json-decode resp-body) #{})))) ;; --------------------------------------------------------------- ;; Main API functions (requires auth) ;; --------------------------------------------------------------- ;;; List license keys with optional filtering and pagination. (define (ls-license-keys client . rest) (apply ls-list-endpoint client parse-license-key '("v1" "license-keys") rest)) ;;; Get a single license key by ID (main API). (define (ls-license-key-get client license-key-id) (ls-get-endpoint client parse-license-key '("v1" "license-keys") license-key-id)) ;;; Update a license key. ;;; updates is a dict of attributes (e.g., activation_limit:, disabled:, expires_at:). ;;; Returns an ls-license-key record. (define (ls-license-key-update client license-key-id updates) (let* ((body #{ data: #{ type: "license-keys" id: license-key-id attributes: updates } }) (url (ls-api-url client "v1" "license-keys" license-key-id)) (response (ls-patch/json client url body))) (parse-license-key (jsonapi-data response)))) ;; --------------------------------------------------------------- ;; License API functions (no auth required) ;; --------------------------------------------------------------- ;;; Validate a license key. ;;; license-key: the full license key string ;;; instance-id: optional instance UUID to validate a specific activation ;;; ;;; Returns an ls-license-validation record. ;;; Rate limit: 60 requests/minute. (define (ls-validate-license license-key . rest) (let* ((instance-id (if (null? rest) #f (car rest))) (params (list (cons "license_key" license-key) (cons "instance_id" instance-id))) (url (string-append license-api-base "/v1/licenses/validate")) (data (license-post url params))) (parse-license-validation data))) ;;; Activate a license key. ;;; license-key: the full license key string ;;; instance-name: label for this activation (e.g., "example.com") ;;; ;;; Returns an ls-license-activation record. ;;; Rate limit: 60 requests/minute. (define (ls-activate-license license-key instance-name) (let* ((params (list (cons "license_key" license-key) (cons "instance_name" instance-name))) (url (string-append license-api-base "/v1/licenses/activate")) (data (license-post url params))) (parse-license-activation data))) ;;; Deactivate a license key. ;;; license-key: the full license key string ;;; instance-id: the UUID of the instance to deactivate ;;; ;;; Returns an ls-license-activation record (with deactivated: #t). ;;; Rate limit: 60 requests/minute. (define (ls-deactivate-license license-key instance-id) (let* ((params (list (cons "license_key" license-key) (cons "instance_id" instance-id))) (url (string-append license-api-base "/v1/licenses/deactivate")) (data (license-post url params))) (parse-license-activation data))) ))src/sigil/lemonsqueezy/order.sgladded
;;; (sigil lemonsqueezy order) - Orders, order items, and subscriptions.;;;;;; Orders are created when a customer completes a purchase.;;; Subscriptions represent recurring billing relationships.(define-library (sigil lemonsqueezy order) (import (sigil core) (sigil dict) (sigil string) (sigil struct) (sigil json) (sigil lemonsqueezy)) (export ;; Order records ls-order ls-order? ls-order-id ls-order-store-id ls-order-customer-id ls-order-identifier ls-order-order-number ls-order-user-name ls-order-user-email ls-order-currency ls-order-subtotal ls-order-discount-total ls-order-tax ls-order-total ls-order-total-formatted ls-order-status ls-order-refunded ls-order-test-mode ls-order-created-at ls-order-updated-at ;; Order item records ls-order-item ls-order-item? ls-order-item-id ls-order-item-order-id ls-order-item-product-id ls-order-item-variant-id ls-order-item-product-name ls-order-item-variant-name ls-order-item-price ls-order-item-quantity ls-order-item-created-at ls-order-item-updated-at ;; Subscription records ls-subscription ls-subscription? ls-subscription-id ls-subscription-store-id ls-subscription-customer-id ls-subscription-order-id ls-subscription-product-id ls-subscription-variant-id ls-subscription-product-name ls-subscription-variant-name ls-subscription-user-name ls-subscription-user-email ls-subscription-status ls-subscription-card-brand ls-subscription-card-last-four ls-subscription-trial-ends-at ls-subscription-renews-at ls-subscription-ends-at ls-subscription-cancelled ls-subscription-test-mode ls-subscription-created-at ls-subscription-updated-at ls-subscription-urls ;; Parsing parse-order parse-order-item parse-subscription ;; Order API functions ls-orders ls-order-get ls-order-items ls-order-item-get ;; Subscription API functions ls-subscriptions ls-subscription-get ls-subscription-update ls-subscription-cancel) (begin ;; --------------------------------------------------------------- ;; Records ;; --------------------------------------------------------------- (define-struct ls-order (id) (store-id default: #f) (customer-id default: #f) (identifier default: "") (order-number default: 0) (user-name default: "") (user-email default: "") (currency default: "USD") (subtotal default: 0) (discount-total default: 0) (tax default: 0) (total default: 0) (total-formatted default: "") (status default: "pending") (refunded default: #f) (test-mode default: #f) (created-at default: #f) (updated-at default: #f)) (define-struct ls-order-item (id) (order-id default: #f) (product-id default: #f) (variant-id default: #f) (product-name default: "") (variant-name default: "") (price default: 0) (quantity default: 1) (created-at default: #f) (updated-at default: #f)) (define-struct ls-subscription (id) (store-id default: #f) (customer-id default: #f) (order-id default: #f) (product-id default: #f) (variant-id default: #f) (product-name default: "") (variant-name default: "") (user-name default: "") (user-email default: "") (status default: "active") (card-brand default: #f) (card-last-four default: #f) (trial-ends-at default: #f) (renews-at default: #f) (ends-at default: #f) (cancelled default: #f) (test-mode default: #f) (created-at default: #f) (updated-at default: #f) (urls default: #{})) ;; --------------------------------------------------------------- ;; Parsing ;; --------------------------------------------------------------- ;;; Parse a JSON:API order resource into an ls-order record. (define (parse-order resource) (ls-order id: (jsonapi-id resource) store-id: (jsonapi-attr resource store_id: #f) customer-id: (jsonapi-attr resource customer_id: #f) identifier: (jsonapi-attr resource identifier: "") order-number: (jsonapi-attr resource order_number: 0) user-name: (jsonapi-attr resource user_name: "") user-email: (jsonapi-attr resource user_email: "") currency: (jsonapi-attr resource currency: "USD") subtotal: (jsonapi-attr resource subtotal: 0) discount-total: (jsonapi-attr resource discount_total: 0) tax: (jsonapi-attr resource tax: 0) total: (jsonapi-attr resource total: 0) total-formatted: (jsonapi-attr resource total_formatted: "") status: (jsonapi-attr resource status: "pending") refunded: (jsonapi-attr resource refunded: #f) test-mode: (jsonapi-attr resource test_mode: #f) created-at: (jsonapi-attr resource created_at: #f) updated-at: (jsonapi-attr resource updated_at: #f))) ;;; Parse a JSON:API order item resource into an ls-order-item record. (define (parse-order-item resource) (ls-order-item id: (jsonapi-id resource) order-id: (jsonapi-attr resource order_id: #f) product-id: (jsonapi-attr resource product_id: #f) variant-id: (jsonapi-attr resource variant_id: #f) product-name: (jsonapi-attr resource product_name: "") variant-name: (jsonapi-attr resource variant_name: "") price: (jsonapi-attr resource price: 0) quantity: (jsonapi-attr resource quantity: 1) created-at: (jsonapi-attr resource created_at: #f) updated-at: (jsonapi-attr resource updated_at: #f))) ;;; Parse a JSON:API subscription resource into an ls-subscription record. (define (parse-subscription resource) (ls-subscription id: (jsonapi-id resource) store-id: (jsonapi-attr resource store_id: #f) customer-id: (jsonapi-attr resource customer_id: #f) order-id: (jsonapi-attr resource order_id: #f) product-id: (jsonapi-attr resource product_id: #f) variant-id: (jsonapi-attr resource variant_id: #f) product-name: (jsonapi-attr resource product_name: "") variant-name: (jsonapi-attr resource variant_name: "") user-name: (jsonapi-attr resource user_name: "") user-email: (jsonapi-attr resource user_email: "") status: (jsonapi-attr resource status: "active") card-brand: (jsonapi-attr resource card_brand: #f) card-last-four: (jsonapi-attr resource card_last_four: #f) trial-ends-at: (jsonapi-attr resource trial_ends_at: #f) renews-at: (jsonapi-attr resource renews_at: #f) ends-at: (jsonapi-attr resource ends_at: #f) cancelled: (jsonapi-attr resource cancelled: #f) test-mode: (jsonapi-attr resource test_mode: #f) created-at: (jsonapi-attr resource created_at: #f) updated-at: (jsonapi-attr resource updated_at: #f) urls: (let ((attrs (jsonapi-attrs resource))) (dict-ref attrs urls: #{})))) ;; --------------------------------------------------------------- ;; Order API functions ;; --------------------------------------------------------------- ;;; List orders with optional filtering and pagination. (define (ls-orders client . rest) (apply ls-list-endpoint client parse-order '("v1" "orders") rest)) ;;; Get a single order by ID. (define (ls-order-get client order-id) (ls-get-endpoint client parse-order '("v1" "orders") order-id)) ;;; List order items with optional filtering. (define (ls-order-items client . rest) (apply ls-list-endpoint client parse-order-item '("v1" "order-items") rest)) ;;; Get a single order item by ID. (define (ls-order-item-get client order-item-id) (ls-get-endpoint client parse-order-item '("v1" "order-items") order-item-id)) ;; --------------------------------------------------------------- ;; Subscription API functions ;; --------------------------------------------------------------- ;;; List subscriptions with optional filtering and pagination. (define (ls-subscriptions client . rest) (apply ls-list-endpoint client parse-subscription '("v1" "subscriptions") rest)) ;;; Get a single subscription by ID. (define (ls-subscription-get client subscription-id) (ls-get-endpoint client parse-subscription '("v1" "subscriptions") subscription-id)) ;;; Update a subscription. ;;; updates is a dict of attributes to change (e.g., variant_id:, billing_anchor:). ;;; Returns an ls-subscription record. (define (ls-subscription-update client subscription-id updates) (let* ((body #{ data: #{ type: "subscriptions" id: subscription-id attributes: updates } }) (url (ls-api-url client "v1" "subscriptions" subscription-id)) (response (ls-patch/json client url body))) (parse-subscription (jsonapi-data response)))) ;;; Cancel a subscription. ;;; Sets status to "cancelled"; access continues until ends_at. ;;; Returns an ls-subscription record. (define (ls-subscription-cancel client subscription-id) (let* ((url (ls-api-url client "v1" "subscriptions" subscription-id)) (response (ls-delete/json client url))) (if (and response (dict? response)) (parse-subscription (jsonapi-data response)) #t))) ))src/sigil/lemonsqueezy/product.sgladded
;;; (sigil lemonsqueezy product) - Product and Variant management.;;;;;; Products and variants are read-only via the API (created in dashboard).;;; Products describe digital goods; variants represent purchasable;;; configurations (tiers, plans).(define-library (sigil lemonsqueezy product) (import (sigil core) (sigil dict) (sigil string) (sigil struct) (sigil lemonsqueezy)) (export ;; Records ls-product ls-product? ls-product-id ls-product-name ls-product-slug ls-product-description ls-product-status ls-product-price ls-product-price-formatted ls-product-buy-now-url ls-product-store-id ls-product-test-mode ls-product-created-at ls-product-updated-at ls-variant ls-variant? ls-variant-id ls-variant-name ls-variant-slug ls-variant-description ls-variant-price ls-variant-sort ls-variant-status ls-variant-product-id ls-variant-has-license-keys ls-variant-license-activation-limit ls-variant-created-at ls-variant-updated-at ;; Parsing parse-product parse-variant ;; API functions ls-products ls-product-get ls-variants ls-variant-get) (begin ;; --------------------------------------------------------------- ;; Records ;; --------------------------------------------------------------- (define-struct ls-product (id) (name default: "") (slug default: "") (description default: "") (status default: "draft") (price default: 0) (price-formatted default: "") (buy-now-url default: #f) (store-id default: #f) (test-mode default: #f) (created-at default: #f) (updated-at default: #f)) (define-struct ls-variant (id) (name default: "") (slug default: "") (description default: "") (price default: 0) (sort default: 0) (status default: "pending") (product-id default: #f) (has-license-keys default: #f) (license-activation-limit default: 0) (created-at default: #f) (updated-at default: #f)) ;; --------------------------------------------------------------- ;; Parsing ;; --------------------------------------------------------------- ;;; Parse a JSON:API product resource into an ls-product record. (define (parse-product resource) (ls-product id: (jsonapi-id resource) name: (jsonapi-attr resource name: "") slug: (jsonapi-attr resource slug: "") description: (jsonapi-attr resource description: "") status: (jsonapi-attr resource status: "draft") price: (jsonapi-attr resource price: 0) price-formatted: (jsonapi-attr resource price_formatted: "") buy-now-url: (jsonapi-attr resource buy_now_url: #f) store-id: (jsonapi-attr resource store_id: #f) test-mode: (jsonapi-attr resource test_mode: #f) created-at: (jsonapi-attr resource created_at: #f) updated-at: (jsonapi-attr resource updated_at: #f))) ;;; Parse a JSON:API variant resource into an ls-variant record. (define (parse-variant resource) (ls-variant id: (jsonapi-id resource) name: (jsonapi-attr resource name: "") slug: (jsonapi-attr resource slug: "") description: (jsonapi-attr resource description: "") price: (jsonapi-attr resource price: 0) sort: (jsonapi-attr resource sort: 0) status: (jsonapi-attr resource status: "pending") product-id: (jsonapi-attr resource product_id: #f) has-license-keys: (jsonapi-attr resource has_license_keys: #f) license-activation-limit: (jsonapi-attr resource license_activation_limit: 0) created-at: (jsonapi-attr resource created_at: #f) updated-at: (jsonapi-attr resource updated_at: #f))) ;; --------------------------------------------------------------- ;; API functions ;; --------------------------------------------------------------- ;;; List products with optional filtering and pagination. ;;; Returns a list of ls-product records. (define (ls-products client . rest) (apply ls-list-endpoint client parse-product '("v1" "products") rest)) ;;; Get a single product by ID. (define (ls-product-get client product-id) (ls-get-endpoint client parse-product '("v1" "products") product-id)) ;;; List variants with optional filtering and pagination. ;;; Returns a list of ls-variant records. (define (ls-variants client . rest) (apply ls-list-endpoint client parse-variant '("v1" "variants") rest)) ;;; Get a single variant by ID. (define (ls-variant-get client variant-id) (ls-get-endpoint client parse-variant '("v1" "variants") variant-id)) ))src/sigil/lemonsqueezy/webhook.sgladded
;;; (sigil lemonsqueezy webhook) - Webhook management and verification.;;;;;; Handles HMAC-SHA256 signature verification of incoming webhooks,;;; parsing of webhook payloads, and CRUD operations for webhook endpoints.;;;;;; Webhook payloads use a modified JSON:API format with a top-level;;; meta field containing event_name and custom_data.(define-library (sigil lemonsqueezy webhook) (import (sigil core) (sigil dict) (sigil string) (sigil struct) (sigil json) (sigil crypto) (sigil lemonsqueezy)) (export ;; Records ls-webhook ls-webhook? ls-webhook-id ls-webhook-url ls-webhook-events ls-webhook-store-id ls-webhook-test-mode ls-webhook-created-at ls-webhook-updated-at ls-webhook-event ls-webhook-event? ls-webhook-event-name ls-webhook-event-data ls-webhook-event-custom-data ls-webhook-event-meta ;; Parsing parse-webhook parse-webhook-event ;; Verification ls-verify-webhook ;; API functions ls-create-webhook ls-webhooks ls-webhook-get ls-webhook-update ls-webhook-delete) (begin ;; --------------------------------------------------------------- ;; Records ;; --------------------------------------------------------------- (define-struct ls-webhook (id) (url default: "") (events default: '()) (store-id default: #f) (test-mode default: #f) (created-at default: #f) (updated-at default: #f)) (define-struct ls-webhook-event (name) (data default: #{}) (custom-data default: #{}) (meta default: #{})) ;; --------------------------------------------------------------- ;; Parsing ;; --------------------------------------------------------------- ;;; Parse a JSON:API webhook resource into an ls-webhook record. (define (parse-webhook resource) (let ((events-raw (jsonapi-attr resource events: #f))) (ls-webhook id: (jsonapi-id resource) url: (jsonapi-attr resource url: "") events: (if (and events-raw (array? events-raw)) (array->list events-raw) (if (and events-raw (list? events-raw)) events-raw '())) store-id: (jsonapi-attr resource store_id: #f) test-mode: (jsonapi-attr resource test_mode: #f) created-at: (jsonapi-attr resource created_at: #f) updated-at: (jsonapi-attr resource updated_at: #f)))) ;;; Parse a raw webhook payload (as a JSON string or parsed dict) ;;; into an ls-webhook-event record. ;;; ;;; The payload has the structure: ;;; { meta: { event_name, custom_data }, data: { type, id, attributes, ... } } (define (parse-webhook-event payload) (let* ((parsed (if (string? payload) (json-decode payload) payload)) (meta (dict-ref parsed meta: #{})) (event-name (dict-ref meta event_name: "")) (custom-data (let ((cd (dict-ref meta custom_data: #f))) (if (or (not cd) (eq? cd 'null)) #{} cd))) (data (dict-ref parsed data: #{}))) (ls-webhook-event name: event-name data: data custom-data: custom-data meta: meta))) ;; --------------------------------------------------------------- ;; Verification ;; --------------------------------------------------------------- ;;; Verify a webhook signature using HMAC-SHA256. ;;; ;;; secret: the webhook signing secret (string) ;;; raw-body: the raw request body (string, NOT parsed JSON) ;;; signature: the X-Signature header value (hex string) ;;; ;;; Returns #t if the signature is valid, #f otherwise. ;;; Uses timing-safe comparison to prevent timing attacks. (define (ls-verify-webhook secret raw-body signature) (let ((computed (hmac-sha256 secret raw-body))) (timing-safe-equal? computed signature))) ;;; Timing-safe string comparison to prevent timing attacks. ;;; Compares every character regardless of mismatches. (define (timing-safe-equal? a b) (if (not (= (string-length a) (string-length b))) #f (let loop ((i 0) (diff 0)) (if (>= i (string-length a)) (= diff 0) (loop (+ i 1) (+ diff (if (char=? (string-ref a i) (string-ref b i)) 0 1))))))) ;; --------------------------------------------------------------- ;; API functions ;; --------------------------------------------------------------- ;;; Create a webhook endpoint. ;;; ;;; store-id: the store ID (string) ;;; url: the webhook URL to receive events ;;; events: list of event name strings (e.g., '("order_created" "subscription_created")) ;;; secret: signing secret for HMAC verification (6-40 chars recommended) ;;; ;;; Returns an ls-webhook record. (define (ls-create-webhook client store-id webhook-url events secret) (let* ((body #{ data: #{ type: "webhooks" attributes: #{ url: webhook-url events: (list->array events) secret: secret } relationships: #{ store: #{ data: #{ type: "stores" id: store-id } } } } }) (api-url (ls-api-url client "v1" "webhooks")) (response (ls-post/json client api-url body))) (parse-webhook (jsonapi-data response)))) ;;; List webhooks with optional filtering and pagination. (define (ls-webhooks client . rest) (apply ls-list-endpoint client parse-webhook '("v1" "webhooks") rest)) ;;; Get a single webhook by ID. (define (ls-webhook-get client webhook-id) (ls-get-endpoint client parse-webhook '("v1" "webhooks") webhook-id)) ;;; Update a webhook. ;;; updates is a dict of attributes to change (e.g., url:, events:, secret:). ;;; Returns an ls-webhook record. (define (ls-webhook-update client webhook-id updates) (let* ((body #{ data: #{ type: "webhooks" id: webhook-id attributes: updates } }) (url (ls-api-url client "v1" "webhooks" webhook-id)) (response (ls-patch/json client url body))) (parse-webhook (jsonapi-data response)))) ;;; Delete a webhook by ID. (define (ls-webhook-delete client webhook-id) (let ((url (ls-api-url client "v1" "webhooks" webhook-id))) (ls-delete/json client url) #t)) ))test/lemonsqueezy-test.sgladded
;;; Tests for sigil-lemonsqueezy;;;;;; Tests JSON:API parsing, record construction, webhook verification,;;; and query parameter building using response fixtures.(import (sigil core) (sigil dict) (sigil string) (sigil struct) (sigil json) (sigil crypto) (sigil test) (sigil lemonsqueezy) (sigil lemonsqueezy product) (sigil lemonsqueezy checkout) (sigil lemonsqueezy order) (sigil lemonsqueezy webhook) (sigil lemonsqueezy license));; ---------------------------------------------------------------;; Test fixtures — JSON:API response samples;; ---------------------------------------------------------------(define product-response-json (json-decode (string-append "{\"data\": {\"type\": \"products\", \"id\": \"1\"," " \"attributes\": {" " \"store_id\": 1," " \"name\": \"Course Bundle\"," " \"slug\": \"course-bundle\"," " \"description\": \"<p>All courses</p>\"," " \"status\": \"published\"," " \"price\": 9999," " \"price_formatted\": \"$99.99\"," " \"buy_now_url\": \"https://store.example.com/buy/1\"," " \"test_mode\": false," " \"created_at\": \"2026-01-15T10:30:00.000000Z\"," " \"updated_at\": \"2026-03-01T08:00:00.000000Z\"" " }," " \"relationships\": {" " \"store\": {\"data\": {\"type\": \"stores\", \"id\": \"1\"}}," " \"variants\": {\"data\": [" " {\"type\": \"variants\", \"id\": \"10\"}," " {\"type\": \"variants\", \"id\": \"11\"}" " ]}" " }}}")))(define products-list-json (json-decode (string-append "{\"data\": [" " {\"type\": \"products\", \"id\": \"1\"," " \"attributes\": {\"name\": \"Course A\", \"status\": \"published\"," " \"price\": 4999, \"price_formatted\": \"$49.99\"," " \"store_id\": 1, \"test_mode\": false," " \"created_at\": \"2026-01-01T00:00:00.000000Z\"," " \"updated_at\": \"2026-01-01T00:00:00.000000Z\"}}," " {\"type\": \"products\", \"id\": \"2\"," " \"attributes\": {\"name\": \"Course B\", \"status\": \"draft\"," " \"price\": 2999, \"price_formatted\": \"$29.99\"," " \"store_id\": 1, \"test_mode\": true," " \"created_at\": \"2026-02-01T00:00:00.000000Z\"," " \"updated_at\": \"2026-02-01T00:00:00.000000Z\"}}" "]," "\"meta\": {\"page\": {\"currentPage\": 1, \"lastPage\": 1," " \"perPage\": 10, \"total\": 2, \"from\": 1, \"to\": 2}}," "\"links\": {\"first\": \"https://api.lemonsqueezy.com/v1/products?page%5Bnumber%5D=1\"," " \"last\": \"https://api.lemonsqueezy.com/v1/products?page%5Bnumber%5D=1\"}}")))(define variant-response-json (json-decode (string-append "{\"data\": {\"type\": \"variants\", \"id\": \"10\"," " \"attributes\": {" " \"product_id\": 1," " \"name\": \"Standard\"," " \"slug\": \"standard\"," " \"description\": \"Standard tier\"," " \"price\": 4999," " \"sort\": 1," " \"status\": \"published\"," " \"has_license_keys\": true," " \"license_activation_limit\": 3," " \"created_at\": \"2026-01-15T10:30:00.000000Z\"," " \"updated_at\": \"2026-03-01T08:00:00.000000Z\"" " }}}")))(define order-response-json (json-decode (string-append "{\"data\": {\"type\": \"orders\", \"id\": \"100\"," " \"attributes\": {" " \"store_id\": 1," " \"customer_id\": 50," " \"identifier\": \"a1b2c3d4-e5f6-7890-abcd-ef1234567890\"," " \"order_number\": 42," " \"user_name\": \"Alice Smith\"," " \"user_email\": \"[email protected]\"," " \"currency\": \"USD\"," " \"subtotal\": 9999," " \"discount_total\": 0," " \"tax\": 850," " \"total\": 10849," " \"total_formatted\": \"$108.49\"," " \"status\": \"paid\"," " \"refunded\": false," " \"test_mode\": false," " \"created_at\": \"2026-03-20T14:00:00.000000Z\"," " \"updated_at\": \"2026-03-20T14:00:00.000000Z\"" " }}}")))(define order-item-json (json-decode (string-append "{\"data\": {\"type\": \"order-items\", \"id\": \"200\"," " \"attributes\": {" " \"order_id\": 100," " \"product_id\": 1," " \"variant_id\": 10," " \"product_name\": \"Course Bundle\"," " \"variant_name\": \"Standard\"," " \"price\": 9999," " \"quantity\": 1," " \"created_at\": \"2026-03-20T14:00:00.000000Z\"," " \"updated_at\": \"2026-03-20T14:00:00.000000Z\"" " }}}")))(define subscription-response-json (json-decode (string-append "{\"data\": {\"type\": \"subscriptions\", \"id\": \"300\"," " \"attributes\": {" " \"store_id\": 1," " \"customer_id\": 50," " \"order_id\": 100," " \"product_id\": 1," " \"variant_id\": 10," " \"product_name\": \"Pro Plan\"," " \"variant_name\": \"Monthly\"," " \"user_name\": \"Alice Smith\"," " \"user_email\": \"[email protected]\"," " \"status\": \"active\"," " \"card_brand\": \"visa\"," " \"card_last_four\": \"4242\"," " \"trial_ends_at\": null," " \"renews_at\": \"2026-04-20T14:00:00.000000Z\"," " \"ends_at\": null," " \"cancelled\": false," " \"test_mode\": false," " \"created_at\": \"2026-03-20T14:00:00.000000Z\"," " \"updated_at\": \"2026-03-20T14:00:00.000000Z\"," " \"urls\": {" " \"update_payment_method\": \"https://example.lemonsqueezy.com/billing\"," " \"customer_portal\": \"https://example.lemonsqueezy.com/portal\"" " }" " }}}")))(define checkout-response-json (json-decode (string-append "{\"data\": {\"type\": \"checkouts\", \"id\": \"ch_abc123\"," " \"attributes\": {" " \"store_id\": 1," " \"variant_id\": 10," " \"url\": \"https://example.lemonsqueezy.com/checkout/buy/abc123\"," " \"custom_price\": null," " \"expires_at\": \"2026-04-01T00:00:00.000000Z\"," " \"created_at\": \"2026-03-25T10:00:00.000000Z\"," " \"updated_at\": \"2026-03-25T10:00:00.000000Z\"," " \"test_mode\": true" " }}}")))(define webhook-resource-json (json-decode (string-append "{\"data\": {\"type\": \"webhooks\", \"id\": \"500\"," " \"attributes\": {" " \"store_id\": 1," " \"url\": \"https://myapp.example.com/webhooks/ls\"," " \"events\": [\"order_created\", \"subscription_created\"," " \"subscription_expired\"]," " \"test_mode\": false," " \"created_at\": \"2026-03-20T10:00:00.000000Z\"," " \"updated_at\": \"2026-03-20T10:00:00.000000Z\"" " }}}")))(define webhook-payload-json (string-append "{\"meta\": {" " \"event_name\": \"order_created\"," " \"custom_data\": {\"user_id\": \"123\", \"plan\": \"pro\"}" "}," "\"data\": {" " \"type\": \"orders\"," " \"id\": \"100\"," " \"attributes\": {" " \"store_id\": 1," " \"user_email\": \"[email protected]\"," " \"status\": \"paid\"," " \"total\": 9999" " }" "}}"))(define license-key-json (json-decode (string-append "{\"data\": {\"type\": \"license-keys\", \"id\": \"700\"," " \"attributes\": {" " \"store_id\": 1," " \"customer_id\": 50," " \"order_id\": 100," " \"product_id\": 1," " \"key\": \"38b1460a-5104-4067-a91d-77b872934d51\"," " \"key_short\": \"XXXX-72934d51\"," " \"activation_limit\": 3," " \"instances_count\": 1," " \"disabled\": false," " \"status\": \"active\"," " \"expires_at\": null," " \"created_at\": \"2026-03-20T14:00:00.000000Z\"," " \"updated_at\": \"2026-03-20T14:00:00.000000Z\"" " }}}")))(define license-validation-json (json-decode (string-append "{\"valid\": true, \"error\": null," " \"license_key\": {" " \"id\": 700," " \"status\": \"active\"," " \"key\": \"38b1460a-5104-4067-a91d-77b872934d51\"," " \"activation_limit\": 3," " \"activation_usage\": 1" " }," " \"instance\": {" " \"id\": \"f90ec370-1234-5678-9abc-def012345678\"," " \"name\": \"mysite.com\"," " \"created_at\": \"2026-03-20T14:00:00.000000Z\"" " }," " \"meta\": {" " \"store_id\": 1," " \"order_id\": 100," " \"product_id\": 1," " \"variant_id\": 10," " \"product_name\": \"Course Bundle\"," " \"variant_name\": \"Standard\"," " \"customer_name\": \"Alice Smith\"," " \"customer_email\": \"[email protected]\"" " }}")))(define included-response-json (json-decode (string-append "{\"data\": {\"type\": \"products\", \"id\": \"1\"," " \"attributes\": {\"name\": \"Course Bundle\"}," " \"relationships\": {" " \"variants\": {\"data\": [" " {\"type\": \"variants\", \"id\": \"10\"}," " {\"type\": \"variants\", \"id\": \"11\"}" " ]}" " }}," "\"included\": [" " {\"type\": \"variants\", \"id\": \"10\"," " \"attributes\": {\"name\": \"Standard\", \"price\": 4999}}," " {\"type\": \"variants\", \"id\": \"11\"," " \"attributes\": {\"name\": \"Premium\", \"price\": 9999}}" "]}")));; ---------------------------------------------------------------;; JSON:API helper tests;; ---------------------------------------------------------------(test-group "JSON:API helpers" (test "jsonapi-id extracts resource id" (let ((data (jsonapi-data product-response-json))) (assert-equal "1" (jsonapi-id data)))) (test "jsonapi-type extracts resource type" (let ((data (jsonapi-data product-response-json))) (assert-equal "products" (jsonapi-type data)))) (test "jsonapi-attr extracts attribute" (let ((data (jsonapi-data product-response-json))) (assert-equal "Course Bundle" (jsonapi-attr data name:)) (assert-equal 9999 (jsonapi-attr data price:)) (assert-equal "published" (jsonapi-attr data status:)))) (test "jsonapi-attr returns default for missing attribute" (let ((data (jsonapi-data product-response-json))) (assert-equal "default" (jsonapi-attr data nonexistent: "default")))) (test "jsonapi-attrs returns full attributes dict" (let ((attrs (jsonapi-attrs (jsonapi-data product-response-json)))) (assert-true (dict? attrs)) (assert-equal "Course Bundle" (dict-ref attrs name:)))) (test "jsonapi-relationship-id extracts single relationship" (let ((data (jsonapi-data product-response-json))) (assert-equal "1" (jsonapi-relationship-id data store:)))) (test "jsonapi-relationship-ids extracts has-many relationship" (let ((data (jsonapi-data product-response-json))) (let ((ids (jsonapi-relationship-ids data variants:))) (assert-equal 2 (length ids)) (assert-equal "10" (car ids)) (assert-equal "11" (cadr ids))))) (test "jsonapi-data-list extracts list from array data" (let ((items (jsonapi-data-list products-list-json))) (assert-equal 2 (length items)) (assert-equal "1" (jsonapi-id (car items))) (assert-equal "2" (jsonapi-id (cadr items))))) (test "jsonapi-included extracts included resources" (let ((included (jsonapi-included included-response-json))) (assert-equal 2 (length included)) (assert-equal "variants" (jsonapi-type (car included))))) (test "jsonapi-find-included finds by type and id" (let ((variant (jsonapi-find-included included-response-json "variants" "11"))) (assert-true (dict? variant)) (assert-equal "11" (jsonapi-id variant)) (assert-equal "Premium" (jsonapi-attr variant name:)))) (test "jsonapi-find-included returns #f for missing" (assert-equal #f (jsonapi-find-included included-response-json "variants" "999"))) (test "jsonapi-pagination-meta extracts page info" (let ((meta (jsonapi-pagination-meta products-list-json))) (assert-equal 1 (dict-ref meta current-page:)) (assert-equal 1 (dict-ref meta last-page:)) (assert-equal 10 (dict-ref meta per-page:)) (assert-equal 2 (dict-ref meta total:)))));; ---------------------------------------------------------------;; maybe-null tests;; ---------------------------------------------------------------(test-group "maybe-null" (test "normalizes null symbol to #f" (assert-equal #f (maybe-null 'null))) (test "passes through #f" (assert-equal #f (maybe-null #f))) (test "passes through truthy values" (assert-equal "hello" (maybe-null "hello")) (assert-equal 42 (maybe-null 42))));; ---------------------------------------------------------------;; Product parsing tests;; ---------------------------------------------------------------(test-group "product parsing" (test "parse single product" (let ((p (parse-product (jsonapi-data product-response-json)))) (assert-true (ls-product? p)) (assert-equal "1" (ls-product-id p)) (assert-equal "Course Bundle" (ls-product-name p)) (assert-equal "course-bundle" (ls-product-slug p)) (assert-equal "published" (ls-product-status p)) (assert-equal 9999 (ls-product-price p)) (assert-equal "$99.99" (ls-product-price-formatted p)) (assert-equal "https://store.example.com/buy/1" (ls-product-buy-now-url p)))) (test "parse product list" (let ((products (map parse-product (jsonapi-data-list products-list-json)))) (assert-equal 2 (length products)) (assert-equal "Course A" (ls-product-name (car products))) (assert-equal "Course B" (ls-product-name (cadr products))) (assert-equal "draft" (ls-product-status (cadr products))))));; ---------------------------------------------------------------;; Variant parsing tests;; ---------------------------------------------------------------(test-group "variant parsing" (test "parse single variant" (let ((v (parse-variant (jsonapi-data variant-response-json)))) (assert-true (ls-variant? v)) (assert-equal "10" (ls-variant-id v)) (assert-equal "Standard" (ls-variant-name v)) (assert-equal "standard" (ls-variant-slug v)) (assert-equal 4999 (ls-variant-price v)) (assert-equal 1 (ls-variant-sort v)) (assert-equal "published" (ls-variant-status v)) (assert-equal 1 (ls-variant-product-id v)) (assert-equal #t (ls-variant-has-license-keys v)) (assert-equal 3 (ls-variant-license-activation-limit v)))));; ---------------------------------------------------------------;; Order parsing tests;; ---------------------------------------------------------------(test-group "order parsing" (test "parse single order" (let ((o (parse-order (jsonapi-data order-response-json)))) (assert-true (ls-order? o)) (assert-equal "100" (ls-order-id o)) (assert-equal 1 (ls-order-store-id o)) (assert-equal 50 (ls-order-customer-id o)) (assert-equal "Alice Smith" (ls-order-user-name o)) (assert-equal "[email protected]" (ls-order-user-email o)) (assert-equal "USD" (ls-order-currency o)) (assert-equal 10849 (ls-order-total o)) (assert-equal "$108.49" (ls-order-total-formatted o)) (assert-equal "paid" (ls-order-status o)) (assert-equal #f (ls-order-refunded o)))));; ---------------------------------------------------------------;; Order item parsing tests;; ---------------------------------------------------------------(test-group "order item parsing" (test "parse order item" (let ((oi (parse-order-item (jsonapi-data order-item-json)))) (assert-true (ls-order-item? oi)) (assert-equal "200" (ls-order-item-id oi)) (assert-equal 100 (ls-order-item-order-id oi)) (assert-equal 1 (ls-order-item-product-id oi)) (assert-equal 10 (ls-order-item-variant-id oi)) (assert-equal "Course Bundle" (ls-order-item-product-name oi)) (assert-equal "Standard" (ls-order-item-variant-name oi)) (assert-equal 9999 (ls-order-item-price oi)) (assert-equal 1 (ls-order-item-quantity oi)))));; ---------------------------------------------------------------;; Subscription parsing tests;; ---------------------------------------------------------------(test-group "subscription parsing" (test "parse subscription" (let ((s (parse-subscription (jsonapi-data subscription-response-json)))) (assert-true (ls-subscription? s)) (assert-equal "300" (ls-subscription-id s)) (assert-equal "active" (ls-subscription-status s)) (assert-equal "Pro Plan" (ls-subscription-product-name s)) (assert-equal "Monthly" (ls-subscription-variant-name s)) (assert-equal "Alice Smith" (ls-subscription-user-name s)) (assert-equal "[email protected]" (ls-subscription-user-email s)) (assert-equal "visa" (ls-subscription-card-brand s)) (assert-equal "4242" (ls-subscription-card-last-four s)) (assert-equal "2026-04-20T14:00:00.000000Z" (ls-subscription-renews-at s)) (assert-equal #f (ls-subscription-cancelled s)))) (test "subscription null fields normalized" (let ((s (parse-subscription (jsonapi-data subscription-response-json)))) ;; trial_ends_at and ends_at are null in fixture (assert-equal #f (ls-subscription-trial-ends-at s)) (assert-equal #f (ls-subscription-ends-at s)))) (test "subscription urls preserved" (let ((s (parse-subscription (jsonapi-data subscription-response-json)))) (let ((urls (ls-subscription-urls s))) (assert-true (dict? urls)) (assert-equal "https://example.lemonsqueezy.com/billing" (dict-ref urls update_payment_method:)) (assert-equal "https://example.lemonsqueezy.com/portal" (dict-ref urls customer_portal:))))));; ---------------------------------------------------------------;; Checkout parsing tests;; ---------------------------------------------------------------(test-group "checkout parsing" (test "parse checkout" (let ((c (parse-checkout (jsonapi-data checkout-response-json)))) (assert-true (ls-checkout? c)) (assert-equal "ch_abc123" (ls-checkout-id c)) (assert-equal "https://example.lemonsqueezy.com/checkout/buy/abc123" (ls-checkout-url c)) (assert-equal 1 (ls-checkout-store-id c)) (assert-equal 10 (ls-checkout-variant-id c)) ;; custom_price is null (assert-equal #f (ls-checkout-custom-price c)) (assert-equal "2026-04-01T00:00:00.000000Z" (ls-checkout-expires-at c)) (assert-equal #t (ls-checkout-test-mode c)))));; ---------------------------------------------------------------;; Webhook parsing tests;; ---------------------------------------------------------------(test-group "webhook parsing" (test "parse webhook resource" (let ((w (parse-webhook (jsonapi-data webhook-resource-json)))) (assert-true (ls-webhook? w)) (assert-equal "500" (ls-webhook-id w)) (assert-equal "https://myapp.example.com/webhooks/ls" (ls-webhook-url w)) (assert-equal 3 (length (ls-webhook-events w)))Showing the first 500 of 684 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.