Commit2dd292deRecorded25 Mar 2026Repositorysigil-lemonsqueezy

Implement Lemon Squeezy API client library

Message

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.

Changed
 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(-)
Diff
README.mdmodified
@@ -1,3 +1,197 @@
1
# sigil-lemonsqueezy
2
3
Lemon Squeezy API client library for Sigil
3
No newline at end of file
+4
Lemon Squeezy API client library for Sigil. Provides functions for managing
+5
products, checkouts, orders, subscriptions, webhooks, and license keys via
+6
the [Lemon Squeezy API](https://docs.lemonsqueezy.com/api).
+7
+8
## Features
+9
+10
- **JSON:API parsing** — Helpers for extracting data, attributes,
+11
relationships, and included resources from JSON:API responses
+12
- **Products & Variants** — List and retrieve products and their variants
+13
- **Checkouts** — Create checkout URLs with custom data, product options,
+14
and pricing overrides
+15
- **Orders & Order Items** — List and retrieve orders and line items
+16
- **Subscriptions** — List, retrieve, update, and cancel subscriptions
+17
- **Webhooks** — CRUD operations plus HMAC-SHA256 signature verification
+18
- **License Keys** — Manage keys via the main API; validate, activate, and
+19
deactivate via the unauthenticated License API
+20
- **Pagination** — Built-in support for page-based pagination and filtering
+21
+22
## Modules
+23
+24
| Module | Description |
+25
|--------|-------------|
+26
| `(sigil lemonsqueezy)` | Core client, auth, JSON:API helpers, pagination |
+27
| `(sigil lemonsqueezy product)` | Products and variants |
+28
| `(sigil lemonsqueezy checkout)` | Checkout creation and retrieval |
+29
| `(sigil lemonsqueezy order)` | Orders, order items, subscriptions |
+30
| `(sigil lemonsqueezy webhook)` | Webhook CRUD and signature verification |
+31
| `(sigil lemonsqueezy license)` | License key management and validation |
+32
+33
## Quick Start
+34
+35
```scheme
+36
(import (sigil lemonsqueezy)
+37
(sigil lemonsqueezy product)
+38
(sigil lemonsqueezy order)
+39
(sigil lemonsqueezy checkout))
+40
+41
;; Create a client with your API key
+42
(define client (ls-client api-key: "your-api-key"))
+43
+44
;; List products
+45
(define products (ls-products client))
+46
(for-each (lambda (p)
+47
(display (ls-product-name p))
+48
(display " - ")
+49
(display (ls-product-price-formatted p))
+50
(newline))
+51
products)
+52
+53
;; Get a single product
+54
(define product (ls-product-get client "123"))
+55
+56
;; Create a checkout with custom data
+57
(define checkout
+58
(ls-create-checkout client "1" "10"
+59
#{ checkout-data: #{ email: "[email protected]"
+60
custom: #{ user_id: "42" } } }))
+61
(display (ls-checkout-url checkout))
+62
+63
;; List orders with filtering
+64
(define orders
+65
(ls-orders client #{ filter: #{ store_id: "1" } }))
+66
```
+67
+68
## Webhooks
+69
+70
Verify incoming webhook signatures and parse event payloads:
+71
+72
```scheme
+73
(import (sigil lemonsqueezy)
+74
(sigil lemonsqueezy webhook))
+75
+76
;; Verify the X-Signature header
+77
(if (ls-verify-webhook signing-secret raw-request-body x-signature-header)
+78
(let ((event (parse-webhook-event raw-request-body)))
+79
(let ((event-name (ls-webhook-event-name event))
+80
(custom-data (ls-webhook-event-custom-data event)))
+81
;; Dispatch by event name
+82
(cond
+83
((string=? event-name "order_created")
+84
;; Grant access using custom_data.user_id
+85
(grant-access (dict-ref custom-data user_id:)))
+86
((string=? event-name "subscription_expired")
+87
;; Revoke access
+88
(revoke-access (dict-ref custom-data user_id:))))))
+89
(error "Invalid webhook signature"))
+90
```
+91
+92
## License Keys
+93
+94
The License API endpoints (validate, activate, deactivate) do not require
+95
an API key and are designed for use in client applications:
+96
+97
```scheme
+98
(import (sigil lemonsqueezy license))
+99
+100
;; Validate a license key (no auth needed)
+101
(define result (ls-validate-license "38b1460a-5104-4067-a91d-77b872934d51"))
+102
(if (ls-license-validation-valid result)
+103
(display "License is valid!")
+104
(display "License is invalid"))
+105
+106
;; Activate a license key
+107
(define activation
+108
(ls-activate-license "38b1460a-5104-4067-a91d-77b872934d51" "mysite.com"))
+109
+110
;; Deactivate
+111
(ls-deactivate-license "38b1460a-5104-4067-a91d-77b872934d51" "instance-uuid")
+112
```
+113
+114
## JSON:API Helpers
+115
+116
Lemon Squeezy uses JSON:API format. The core module provides helpers:
+117
+118
```scheme
+119
;; Extract data from a response
+120
(jsonapi-data response) ; single resource
+121
(jsonapi-data-list response) ; list of resources
+122
(jsonapi-attr resource name:) ; get attribute
+123
(jsonapi-attrs resource) ; full attributes dict
+124
+125
;; Relationships
+126
(jsonapi-relationship-id resource store:) ; single relationship ID
+127
(jsonapi-relationship-ids resource variants:) ; has-many relationship IDs
+128
+129
;; Included resources
+130
(jsonapi-included response) ; all included
+131
(jsonapi-find-included response "variants" "10") ; find by type+id
+132
+133
;; Pagination
+134
(jsonapi-pagination-meta response) ; => #{ current-page: 1 last-page: 3 ... }
+135
(ls-paginate (lambda (page) ...)) ; auto-paginate all pages
+136
```
+137
+138
## Pagination and Filtering
+139
+140
All list endpoints accept an options dict:
+141
+142
```scheme
+143
;; Paginate
+144
(ls-products client #{ page: 2 per-page: 50 })
+145
+146
;; Filter
+147
(ls-subscriptions client #{ filter: #{ status: "active" product_id: "1" } })
+148
+149
;; Include related resources
+150
(ls-products client #{ include: "variants,store" })
+151
+152
;; Auto-paginate all pages
+153
(ls-paginate
+154
(lambda (page)
+155
(ls-get/json client
+156
(string-append
+157
(ls-api-url client "v1" "products")
+158
(build-query-string (ls-list-params #{ page: page per-page: 100 }))))))
+159
```
+160
+161
## Building
+162
+163
Requires a C toolchain for native dependencies (sigil-crypto, sigil-tls).
+164
+165
```bash
+166
# With local sigil checkout
+167
sigil build --redirects dev-redirects.sgl
+168
+169
# Run tests
+170
sigil test
+171
```
+172
+173
## Dependencies
+174
+175
- sigil-stdlib (core, dict, string, struct, json)
+176
- sigil-http (HTTP client)
+177
- sigil-tls (HTTPS support)
+178
- sigil-json (JSON parsing)
+179
- sigil-crypto (HMAC-SHA256 for webhook verification)
+180
- sigil-log (logging)
+181
+182
## API Coverage
+183
+184
| Resource | Operations |
+185
|----------|-----------|
+186
| Products | list, get |
+187
| Variants | list, get |
+188
| Checkouts | create, get, list |
+189
| Orders | list, get |
+190
| Order Items | list, get |
+191
| Subscriptions | list, get, update, cancel |
+192
| Webhooks | create, get, list, update, delete, verify |
+193
| License Keys | list, get, update (main API) |
+194
| License API | validate, activate, deactivate (no auth) |
+195
+196
## License
+197
+198
BSD-3-Clause
src/sigil/lemonsqueezy.sgladded
@@ -0,0 +1,375 @@
+1
;;; (sigil lemonsqueezy) - Lemon Squeezy API client library.
+2
;;;
+3
;;; Core module providing authentication, HTTP helpers, JSON:API parsing
+4
;;; utilities, pagination, and record types for the Lemon Squeezy API.
+5
;;;
+6
;;; Lemon Squeezy uses JSON:API format (jsonapi.org) for all responses.
+7
;;; This module provides helpers to extract data from that structure.
+8
+9
(define-library (sigil lemonsqueezy)
+10
(import (sigil core)
+11
(sigil dict)
+12
(sigil string)
+13
(sigil struct)
+14
(sigil json)
+15
(sigil http client))
+16
+17
(export ;; Client
+18
ls-client
+19
ls-client?
+20
ls-client-api-key
+21
ls-client-base-url
+22
+23
;; Shared HTTP helpers (for sub-modules)
+24
ls-auth-headers
+25
ls-api-url
+26
ls-get/json
+27
ls-post/json
+28
ls-patch/json
+29
ls-delete/json
+30
+31
;; JSON:API helpers
+32
jsonapi-id
+33
jsonapi-type
+34
jsonapi-attr
+35
jsonapi-attrs
+36
jsonapi-relationship-id
+37
jsonapi-relationship-ids
+38
jsonapi-data
+39
jsonapi-data-list
+40
jsonapi-included
+41
jsonapi-find-included
+42
jsonapi-meta
+43
jsonapi-links
+44
jsonapi-pagination-meta
+45
maybe-null
+46
+47
;; Pagination
+48
ls-paginate
+49
ls-list-params
+50
+51
;; URL helpers
+52
url-encode-value
+53
build-query-string
+54
+55
;; Generic endpoint helpers
+56
ls-list-endpoint
+57
ls-get-endpoint
+58
+59
;; Error
+60
ls-error)
+61
+62
(begin
+63
+64
;; ---------------------------------------------------------------
+65
;; Records
+66
;; ---------------------------------------------------------------
+67
+68
(define-struct ls-client
+69
(api-key)
+70
(base-url default: "https://api.lemonsqueezy.com"))
+71
+72
;; ---------------------------------------------------------------
+73
;; URL encoding helpers
+74
;; ---------------------------------------------------------------
+75
+76
;;; URL-encode a query parameter value (RFC 3986 unreserved chars).
+77
(define (url-encode-value s)
+78
(let ((len (string-length s)))
+79
(let loop ((i 0) (acc '()))
+80
(if (>= i len)
+81
(list->string (reverse acc))
+82
(let ((c (string-ref s i)))
+83
(cond
+84
((or (char-alphabetic? c)
+85
(char-numeric? c)
+86
(char=? c #\-)
+87
(char=? c #\_)
+88
(char=? c #\.)
+89
(char=? c #\~))
+90
(loop (+ i 1) (cons c acc)))
+91
((char=? c #\space)
+92
(loop (+ i 1) (cons #\+ acc)))
+93
(else
+94
(let ((n (char->integer c)))
+95
(loop (+ i 1)
+96
(append (reverse (string->list
+97
(string-append "%"
+98
(if (< n 16) "0" "")
+99
(number->string n 16))))
+100
acc))))))))))
+101
+102
;;; Build a query string from a list of (key . value) pairs.
+103
;;; Pairs with #f values are omitted.
+104
(define (build-query-string params)
+105
(let ((parts (filter (lambda (p) (cdr p)) params)))
+106
(if (null? parts)
+107
""
+108
(string-append "?"
+109
(string-join
+110
(map (lambda (p)
+111
(string-append (url-encode-value (car p))
+112
"="
+113
(url-encode-value (cdr p))))
+114
parts)
+115
"&")))))
+116
+117
;; ---------------------------------------------------------------
+118
;; Internal helpers
+119
;; ---------------------------------------------------------------
+120
+121
;;; Build authorization headers for the Lemon Squeezy API.
+122
;;; All requests require Accept and Content-Type for JSON:API.
+123
(define (ls-auth-headers client)
+124
#{ authorization: (string-append "Bearer " (ls-client-api-key client))
+125
accept: "application/vnd.api+json"
+126
content-type: "application/vnd.api+json" })
+127
+128
;;; Build an API URL from the client base URL and path segments.
+129
(define (ls-api-url client . parts)
+130
(apply string-append (ls-client-base-url client)
+131
(map (lambda (p) (string-append "/" p)) parts)))
+132
+133
;;; Normalize a JSON null value to #f.
+134
;;; json-decode returns the symbol 'null' for JSON null, which is
+135
;;; truthy in boolean context. This helper normalizes it.
+136
(define (maybe-null v)
+137
(if (or (not v) (eq? v 'null)) #f v))
+138
+139
;;; Raise a Lemon Squeezy API error with context from the response.
+140
(define (ls-error status body)
+141
(let* ((parsed (if (and body (not (string=? body "")))
+142
(guard (exn (else #f))
+143
(json-decode body))
+144
#f))
+145
(errors (if (and parsed (dict? parsed))
+146
(dict-ref parsed errors: #f)
+147
#f))
+148
(detail (if (and errors (array? errors) (> (array-length errors) 0))
+149
(let ((err (array-ref errors 0)))
+150
(dict-ref err detail: "Unknown error"))
+151
"Unknown error")))
+152
(cond
+153
((= status 401)
+154
(error (string-append
+155
"Lemon Squeezy API 401 Unauthorized. "
+156
"Check your API key. " detail)))
+157
((= status 404)
+158
(error (string-append
+159
"Lemon Squeezy API 404 Not Found. " detail)))
+160
((= status 422)
+161
(error (string-append
+162
"Lemon Squeezy API 422 Validation Error. " detail)))
+163
((= status 429)
+164
(error (string-append
+165
"Lemon Squeezy API 429 Rate Limited. "
+166
"Retry after a delay.")))
+167
(else
+168
(error (string-append
+169
"Lemon Squeezy API error " (number->string status)
+170
": " detail))))))
+171
+172
;;; Check an HTTP response and return parsed JSON or raise an error.
+173
(define (check-ls-response response)
+174
(if (not (http-response? response))
+175
(error "Lemon Squeezy API request failed: no response"))
+176
(let ((status (http-response-status response))
+177
(body (http-response-body response)))
+178
(if (>= status 400)
+179
(ls-error status body)
+180
(if (and body (not (string=? body "")))
+181
(json-decode body)
+182
#t))))
+183
+184
;;; Authenticated JSON GET request.
+185
(define (ls-get/json client url)
+186
(check-ls-response
+187
(http-get url headers: (ls-auth-headers client))))
+188
+189
;;; Authenticated JSON POST request.
+190
(define (ls-post/json client url body)
+191
(check-ls-response
+192
(http-post url (if (string? body) body (json-encode body))
+193
headers: (ls-auth-headers client))))
+194
+195
;;; Authenticated JSON PATCH request.
+196
(define (ls-patch/json client url body)
+197
(check-ls-response
+198
(http-patch url (if (string? body) body (json-encode body))
+199
headers: (ls-auth-headers client))))
+200
+201
;;; Authenticated JSON DELETE request.
+202
(define (ls-delete/json client url)
+203
(check-ls-response
+204
(http-delete url headers: (ls-auth-headers client))))
+205
+206
;; ---------------------------------------------------------------
+207
;; Generic endpoint helpers
+208
;; ---------------------------------------------------------------
+209
+210
;;; Fetch a list from a JSON:API endpoint with optional filtering/pagination.
+211
;;; endpoint: the API path segments (e.g., "v1" "products")
+212
;;; parser: function to convert a JSON:API resource to a record
+213
;;; opts: optional dict with page:, per-page:, filter:, include: keys
+214
(define (ls-list-endpoint client parser endpoint . rest)
+215
(let* ((opts (if (null? rest) #{} (car rest)))
+216
(params (ls-list-params opts))
+217
(url (string-append
+218
(apply ls-api-url client endpoint)
+219
(build-query-string params)))
+220
(response (ls-get/json client url)))
+221
(map parser (jsonapi-data-list response))))
+222
+223
;;; Fetch a single resource from a JSON:API endpoint by ID.
+224
;;; endpoint: the API path segments (e.g., "v1" "products")
+225
;;; id: the resource ID string
+226
;;; parser: function to convert a JSON:API resource to a record
+227
(define (ls-get-endpoint client parser endpoint id)
+228
(let* ((url (apply ls-api-url client (append endpoint (list id))))
+229
(response (ls-get/json client url)))
+230
(parser (jsonapi-data response))))
+231
+232
;; ---------------------------------------------------------------
+233
;; JSON:API helpers
+234
;; ---------------------------------------------------------------
+235
+236
;;; Extract the id from a JSON:API resource object.
+237
(define (jsonapi-id resource)
+238
(dict-ref resource id:))
+239
+240
;;; Extract the type from a JSON:API resource object.
+241
(define (jsonapi-type resource)
+242
(dict-ref resource type:))
+243
+244
;;; Extract a single attribute from a JSON:API resource object.
+245
;;; Returns default if the attribute is missing or null.
+246
(define (jsonapi-attr resource key . rest)
+247
(let* ((default (if (null? rest) #f (car rest)))
+248
(attrs (dict-ref resource attributes: #{}))
+249
(val (dict-ref attrs key default)))
+250
(maybe-null val)))
+251
+252
;;; Extract the full attributes dict from a JSON:API resource object.
+253
(define (jsonapi-attrs resource)
+254
(dict-ref resource attributes: #{}))
+255
+256
;;; Extract a single relationship ID from a JSON:API resource.
+257
;;; Relationships are under data.relationships.<name>.data.id
+258
(define (jsonapi-relationship-id resource rel-name)
+259
(let* ((rels (dict-ref resource relationships: #{}))
+260
(rel (dict-ref rels rel-name #{}))
+261
(data (dict-ref rel data: #f)))
+262
(if (and data (dict? data))
+263
(maybe-null (dict-ref data id: #f))
+264
#f)))
+265
+266
;;; Extract relationship IDs for a has-many relationship.
+267
;;; Returns a list of ID strings.
+268
(define (jsonapi-relationship-ids resource rel-name)
+269
(let* ((rels (dict-ref resource relationships: #{}))
+270
(rel (dict-ref rels rel-name #{}))
+271
(data (dict-ref rel data: #f)))
+272
(if (and data (array? data))
+273
(map (lambda (d) (dict-ref d id:)) (array->list data))
+274
'())))
+275
+276
;;; Extract the data field from a JSON:API response.
+277
;;; For single-resource responses, returns the resource object.
+278
(define (jsonapi-data response)
+279
(dict-ref response data:))
+280
+281
;;; Extract data as a list from a JSON:API list response.
+282
;;; For list endpoints, data is an array.
+283
(define (jsonapi-data-list response)
+284
(let ((data (dict-ref response data: #[])))
+285
(if (array? data)
+286
(array->list data)
+287
(list data))))
+288
+289
;;; Extract the included array from a JSON:API response.
+290
;;; Returns a list of included resource objects.
+291
(define (jsonapi-included response)
+292
(let ((included (dict-ref response included: #f)))
+293
(if (and included (array? included))
+294
(array->list included)
+295
'())))
+296
+297
;;; Find an included resource by type and id.
+298
(define (jsonapi-find-included response type id)
+299
(let loop ((items (jsonapi-included response)))
+300
(cond
+301
((null? items) #f)
+302
((and (string=? (dict-ref (car items) type:) type)
+303
(equal? (dict-ref (car items) id:) id))
+304
(car items))
+305
(else (loop (cdr items))))))
+306
+307
;;; Extract meta from a JSON:API response.
+308
(define (jsonapi-meta response)
+309
(dict-ref response meta: #{}))
+310
+311
;;; Extract links from a JSON:API response.
+312
(define (jsonapi-links response)
+313
(dict-ref response links: #{}))
+314
+315
;;; Extract pagination metadata from a JSON:API list response.
+316
;;; Returns a dict with current-page, last-page, per-page, total, from, to.
+317
(define (jsonapi-pagination-meta response)
+318
(let ((meta (jsonapi-meta response)))
+319
(let ((page (dict-ref meta page: #{})))
+320
#{ current-page: (dict-ref page currentPage: 1)
+321
last-page: (dict-ref page lastPage: 1)
+322
per-page: (dict-ref page perPage: 10)
+323
total: (dict-ref page total: 0)
+324
from: (dict-ref page from: #f)
+325
to: (dict-ref page to: #f) })))
+326
+327
;; ---------------------------------------------------------------
+328
;; Pagination & Filtering
+329
;; ---------------------------------------------------------------
+330
+331
;;; Build query parameters for list endpoints.
+332
;;; Takes an optional dict with page:, per-page:, filter:, and include: keys.
+333
;;; filter: should be a dict of filter params (e.g., #{ store_id: "1" }).
+334
;;; include: should be a comma-separated string (e.g., "variants,store").
+335
(define (ls-list-params . rest)
+336
(let ((opts (if (null? rest) #{} (car rest))))
+337
(let ((page (dict-ref opts page: #f))
+338
(per-page (dict-ref opts per-page: #f))
+339
(filters (dict-ref opts filter: #f))
+340
(include (dict-ref opts include: #f)))
+341
(let ((base-params
+342
(list (cons "page[number]"
+343
(if page (number->string page) #f))
+344
(cons "page[size]"
+345
(if per-page (number->string per-page) #f))
+346
(cons "include" include))))
+347
(if (and filters (dict? filters))
+348
(append base-params
+349
(map (lambda (entry)
+350
(cons (string-append "filter["
+351
(keyword->string (car entry)) "]")
+352
(if (number? (cdr entry))
+353
(number->string (cdr entry))
+354
(cdr entry))))
+355
(dict-entries filters)))
+356
base-params)))))
+357
+358
;;; Fetch all pages from a paginated list endpoint.
+359
;;; Calls fetcher with successive page numbers and accumulates results.
+360
;;; fetcher should be a function taking a page number and returning
+361
;;; a JSON:API response.
+362
;;; Returns a list of all resource objects across all pages.
+363
(define (ls-paginate fetcher)
+364
(let loop ((page 1) (chunks '()))
+365
(let* ((response (fetcher page))
+366
(items (jsonapi-data-list response))
+367
(meta (jsonapi-pagination-meta response))
+368
(current (dict-ref meta current-page:))
+369
(last-page (dict-ref meta last-page:))
+370
(chunks (cons items chunks)))
+371
(if (>= current last-page)
+372
(apply append (reverse chunks))
+373
(loop (+ page 1) chunks)))))
+374
+375
))
src/sigil/lemonsqueezy/checkout.sgladded
@@ -0,0 +1,119 @@
+1
;;; (sigil lemonsqueezy checkout) - Checkout creation and retrieval.
+2
;;;
+3
;;; Checkouts create purchasable links for specific variants. This is the
+4
;;; primary way to initiate purchases programmatically. Custom data passed
+5
;;; through checkouts flows to webhooks via meta.custom_data.
+6
+7
(define-library (sigil lemonsqueezy checkout)
+8
(import (sigil core)
+9
(sigil dict)
+10
(sigil string)
+11
(sigil struct)
+12
(sigil json)
+13
(sigil lemonsqueezy))
+14
+15
(export ;; Records
+16
ls-checkout
+17
ls-checkout?
+18
ls-checkout-id
+19
ls-checkout-url
+20
ls-checkout-store-id
+21
ls-checkout-variant-id
+22
ls-checkout-custom-price
+23
ls-checkout-expires-at
+24
ls-checkout-created-at
+25
ls-checkout-updated-at
+26
ls-checkout-test-mode
+27
+28
;; Parsing
+29
parse-checkout
+30
+31
;; API functions
+32
ls-create-checkout
+33
ls-checkout-get
+34
ls-checkouts)
+35
+36
(begin
+37
+38
;; ---------------------------------------------------------------
+39
;; Records
+40
;; ---------------------------------------------------------------
+41
+42
(define-struct ls-checkout
+43
(id)
+44
(url default: #f)
+45
(store-id default: #f)
+46
(variant-id default: #f)
+47
(custom-price default: #f)
+48
(expires-at default: #f)
+49
(created-at default: #f)
+50
(updated-at default: #f)
+51
(test-mode default: #f))
+52
+53
;; ---------------------------------------------------------------
+54
;; Parsing
+55
;; ---------------------------------------------------------------
+56
+57
;;; Parse a JSON:API checkout resource into an ls-checkout record.
+58
(define (parse-checkout resource)
+59
(ls-checkout
+60
id: (jsonapi-id resource)
+61
url: (jsonapi-attr resource url: #f)
+62
store-id: (jsonapi-attr resource store_id: #f)
+63
variant-id: (jsonapi-attr resource variant_id: #f)
+64
custom-price: (jsonapi-attr resource custom_price: #f)
+65
expires-at: (jsonapi-attr resource expires_at: #f)
+66
created-at: (jsonapi-attr resource created_at: #f)
+67
updated-at: (jsonapi-attr resource updated_at: #f)
+68
test-mode: (jsonapi-attr resource test_mode: #f)))
+69
+70
;; ---------------------------------------------------------------
+71
;; API functions
+72
;; ---------------------------------------------------------------
+73
+74
;;; Create a checkout for a store and variant.
+75
;;;
+76
;;; store-id and variant-id are required (as strings).
+77
;;; opts is a dict with optional keys:
+78
;;; custom-price: — price in cents (integer)
+79
;;; product-options: — dict of product display overrides
+80
;;; checkout-options: — dict of checkout UI options
+81
;;; checkout-data: — dict with email:, name:, custom:, discount_code:, etc.
+82
;;; expires-at: — ISO 8601 expiration time
+83
;;; preview: — boolean, include pricing breakdown
+84
;;;
+85
;;; Returns an ls-checkout record.
+86
(define (ls-create-checkout client store-id variant-id . rest)
+87
(let* ((opts (if (null? rest) #{} (car rest)))
+88
;; Build attributes from optional fields
+89
(optional-fields
+90
(filter cdr
+91
(list (cons custom_price: (dict-ref opts custom-price: #f))
+92
(cons product_options: (dict-ref opts product-options: #f))
+93
(cons checkout_options: (dict-ref opts checkout-options: #f))
+94
(cons checkout_data: (dict-ref opts checkout-data: #f))
+95
(cons expires_at: (dict-ref opts expires-at: #f))
+96
(cons preview: (dict-ref opts preview: #f)))))
+97
(attrs (apply dict (apply append
+98
(map (lambda (p) (list (car p) (cdr p))) optional-fields))))
+99
(body #{ data:
+100
#{ type: "checkouts"
+101
attributes: attrs
+102
relationships:
+103
#{ store:
+104
#{ data: #{ type: "stores" id: store-id } }
+105
variant:
+106
#{ data: #{ type: "variants" id: variant-id } } } } })
+107
(url (ls-api-url client "v1" "checkouts"))
+108
(response (ls-post/json client url body)))
+109
(parse-checkout (jsonapi-data response))))
+110
+111
;;; Get a single checkout by ID.
+112
(define (ls-checkout-get client checkout-id)
+113
(ls-get-endpoint client parse-checkout '("v1" "checkouts") checkout-id))
+114
+115
;;; List checkouts with optional filtering and pagination.
+116
(define (ls-checkouts client . rest)
+117
(apply ls-list-endpoint client parse-checkout '("v1" "checkouts") rest))
+118
+119
))
src/sigil/lemonsqueezy/license.sgladded
@@ -0,0 +1,252 @@
+1
;;; (sigil lemonsqueezy license) - License key management.
+2
;;;
+3
;;; Two separate APIs:
+4
;;; 1. Main API (requires Bearer auth) — list/retrieve/update license keys
+5
;;; 2. License API (no auth needed) — validate/activate/deactivate keys
+6
;;;
+7
;;; The License API is designed for client applications and uses different
+8
;;; headers (application/json, application/x-www-form-urlencoded).
+9
+10
(define-library (sigil lemonsqueezy license)
+11
(import (sigil core)
+12
(sigil dict)
+13
(sigil string)
+14
(sigil struct)
+15
(sigil json)
+16
(sigil http client)
+17
(sigil lemonsqueezy))
+18
+19
(export ;; Records
+20
ls-license-key
+21
ls-license-key?
+22
ls-license-key-id
+23
ls-license-key-store-id
+24
ls-license-key-customer-id
+25
ls-license-key-order-id
+26
ls-license-key-product-id
+27
ls-license-key-key
+28
ls-license-key-key-short
+29
ls-license-key-activation-limit
+30
ls-license-key-instances-count
+31
ls-license-key-disabled
+32
ls-license-key-status
+33
ls-license-key-expires-at
+34
ls-license-key-created-at
+35
ls-license-key-updated-at
+36
+37
ls-license-validation
+38
ls-license-validation?
+39
ls-license-validation-valid
+40
ls-license-validation-error
+41
ls-license-validation-license-key
+42
ls-license-validation-instance
+43
ls-license-validation-meta
+44
+45
ls-license-activation
+46
ls-license-activation?
+47
ls-license-activation-activated
+48
ls-license-activation-error
+49
ls-license-activation-license-key
+50
ls-license-activation-instance
+51
ls-license-activation-meta
+52
+53
;; Parsing
+54
parse-license-key
+55
parse-license-validation
+56
parse-license-activation
+57
+58
;; Main API functions (requires auth)
+59
ls-license-keys
+60
ls-license-key-get
+61
ls-license-key-update
+62
+63
;; License API functions (no auth required)
+64
ls-validate-license
+65
ls-activate-license
+66
ls-deactivate-license)
+67
+68
(begin
+69
+70
;; ---------------------------------------------------------------
+71
;; Records
+72
;; ---------------------------------------------------------------
+73
+74
(define-struct ls-license-key
+75
(id)
+76
(store-id default: #f)
+77
(customer-id default: #f)
+78
(order-id default: #f)
+79
(product-id default: #f)
+80
(key default: "")
+81
(key-short default: "")
+82
(activation-limit default: 0)
+83
(instances-count default: 0)
+84
(disabled default: #f)
+85
(status default: "inactive")
+86
(expires-at default: #f)
+87
(created-at default: #f)
+88
(updated-at default: #f))
+89
+90
(define-struct ls-license-validation
+91
(valid default: #f)
+92
(error default: #f)
+93
(license-key default: #{})
+94
(instance default: #f)
+95
(meta default: #{}))
+96
+97
(define-struct ls-license-activation
+98
(activated default: #f)
+99
(error default: #f)
+100
(license-key default: #{})
+101
(instance default: #f)
+102
(meta default: #{}))
+103
+104
;; ---------------------------------------------------------------
+105
;; Parsing
+106
;; ---------------------------------------------------------------
+107
+108
;;; Parse a JSON:API license key resource into an ls-license-key record.
+109
(define (parse-license-key resource)
+110
(ls-license-key
+111
id: (jsonapi-id resource)
+112
store-id: (jsonapi-attr resource store_id: #f)
+113
customer-id: (jsonapi-attr resource customer_id: #f)
+114
order-id: (jsonapi-attr resource order_id: #f)
+115
product-id: (jsonapi-attr resource product_id: #f)
+116
key: (jsonapi-attr resource key: "")
+117
key-short: (jsonapi-attr resource key_short: "")
+118
activation-limit: (jsonapi-attr resource activation_limit: 0)
+119
instances-count: (jsonapi-attr resource instances_count: 0)
+120
disabled: (jsonapi-attr resource disabled: #f)
+121
status: (jsonapi-attr resource status: "inactive")
+122
expires-at: (jsonapi-attr resource expires_at: #f)
+123
created-at: (jsonapi-attr resource created_at: #f)
+124
updated-at: (jsonapi-attr resource updated_at: #f)))
+125
+126
;;; Normalize a dict field: null → empty dict, missing → empty dict.
+127
(define (dict-or-empty data key)
+128
(let ((v (dict-ref data key #f)))
+129
(if (or (not v) (eq? v 'null)) #{} v)))
+130
+131
;;; Parse a License API validation response.
+132
(define (parse-license-validation data)
+133
(ls-license-validation
+134
valid: (dict-ref data valid: #f)
+135
error: (maybe-null (dict-ref data error: #f))
+136
license-key: (dict-or-empty data license_key:)
+137
instance: (maybe-null (dict-ref data instance: #f))
+138
meta: (dict-or-empty data meta:)))
+139
+140
;;; Parse a License API activation/deactivation response.
+141
(define (parse-license-activation data)
+142
(ls-license-activation
+143
activated: (dict-ref data activated: #f)
+144
error: (maybe-null (dict-ref data error: #f))
+145
license-key: (dict-or-empty data license_key:)
+146
instance: (maybe-null (dict-ref data instance: #f))
+147
meta: (dict-or-empty data meta:)))
+148
+149
;; ---------------------------------------------------------------
+150
;; License API helpers (no Bearer auth)
+151
;; ---------------------------------------------------------------
+152
+153
;;; The License API uses different headers than the main API.
+154
(define license-api-headers
+155
#{ accept: "application/json"
+156
content-type: "application/x-www-form-urlencoded" })
+157
+158
;;; License API base URL (hardcoded; does not use client base-url
+159
;;; since these endpoints require no authentication).
+160
(define license-api-base "https://api.lemonsqueezy.com")
+161
+162
;;; Build a form-urlencoded body from an alist of (key . value) pairs.
+163
(define (form-encode-body params)
+164
(string-join
+165
(map (lambda (p)
+166
(string-append (url-encode-value (car p))
+167
"="
+168
(url-encode-value (cdr p))))
+169
(filter (lambda (p) (cdr p)) params))
+170
"&"))
+171
+172
;;; Make a POST request to the License API (form-urlencoded, no auth).
+173
(define (license-post url params)
+174
(let* ((body (form-encode-body params))
+175
(response (http-post url body headers: license-api-headers)))
+176
(if (not (http-response? response))
+177
(error "License API request failed: no response"))
+178
(let ((status (http-response-status response))
+179
(resp-body (http-response-body response)))
+180
(if (and resp-body (not (string=? resp-body "")))
+181
(json-decode resp-body)
+182
#{}))))
+183
+184
;; ---------------------------------------------------------------
+185
;; Main API functions (requires auth)
+186
;; ---------------------------------------------------------------
+187
+188
;;; List license keys with optional filtering and pagination.
+189
(define (ls-license-keys client . rest)
+190
(apply ls-list-endpoint client parse-license-key '("v1" "license-keys") rest))
+191
+192
;;; Get a single license key by ID (main API).
+193
(define (ls-license-key-get client license-key-id)
+194
(ls-get-endpoint client parse-license-key '("v1" "license-keys") license-key-id))
+195
+196
;;; Update a license key.
+197
;;; updates is a dict of attributes (e.g., activation_limit:, disabled:, expires_at:).
+198
;;; Returns an ls-license-key record.
+199
(define (ls-license-key-update client license-key-id updates)
+200
(let* ((body #{ data:
+201
#{ type: "license-keys"
+202
id: license-key-id
+203
attributes: updates } })
+204
(url (ls-api-url client "v1" "license-keys" license-key-id))
+205
(response (ls-patch/json client url body)))
+206
(parse-license-key (jsonapi-data response))))
+207
+208
;; ---------------------------------------------------------------
+209
;; License API functions (no auth required)
+210
;; ---------------------------------------------------------------
+211
+212
;;; Validate a license key.
+213
;;; license-key: the full license key string
+214
;;; instance-id: optional instance UUID to validate a specific activation
+215
;;;
+216
;;; Returns an ls-license-validation record.
+217
;;; Rate limit: 60 requests/minute.
+218
(define (ls-validate-license license-key . rest)
+219
(let* ((instance-id (if (null? rest) #f (car rest)))
+220
(params (list (cons "license_key" license-key)
+221
(cons "instance_id" instance-id)))
+222
(url (string-append license-api-base "/v1/licenses/validate"))
+223
(data (license-post url params)))
+224
(parse-license-validation data)))
+225
+226
;;; Activate a license key.
+227
;;; license-key: the full license key string
+228
;;; instance-name: label for this activation (e.g., "example.com")
+229
;;;
+230
;;; Returns an ls-license-activation record.
+231
;;; Rate limit: 60 requests/minute.
+232
(define (ls-activate-license license-key instance-name)
+233
(let* ((params (list (cons "license_key" license-key)
+234
(cons "instance_name" instance-name)))
+235
(url (string-append license-api-base "/v1/licenses/activate"))
+236
(data (license-post url params)))
+237
(parse-license-activation data)))
+238
+239
;;; Deactivate a license key.
+240
;;; license-key: the full license key string
+241
;;; instance-id: the UUID of the instance to deactivate
+242
;;;
+243
;;; Returns an ls-license-activation record (with deactivated: #t).
+244
;;; Rate limit: 60 requests/minute.
+245
(define (ls-deactivate-license license-key instance-id)
+246
(let* ((params (list (cons "license_key" license-key)
+247
(cons "instance_id" instance-id)))
+248
(url (string-append license-api-base "/v1/licenses/deactivate"))
+249
(data (license-post url params)))
+250
(parse-license-activation data)))
+251
+252
))
src/sigil/lemonsqueezy/order.sgladded
@@ -0,0 +1,273 @@
+1
;;; (sigil lemonsqueezy order) - Orders, order items, and subscriptions.
+2
;;;
+3
;;; Orders are created when a customer completes a purchase.
+4
;;; Subscriptions represent recurring billing relationships.
+5
+6
(define-library (sigil lemonsqueezy order)
+7
(import (sigil core)
+8
(sigil dict)
+9
(sigil string)
+10
(sigil struct)
+11
(sigil json)
+12
(sigil lemonsqueezy))
+13
+14
(export ;; Order records
+15
ls-order
+16
ls-order?
+17
ls-order-id
+18
ls-order-store-id
+19
ls-order-customer-id
+20
ls-order-identifier
+21
ls-order-order-number
+22
ls-order-user-name
+23
ls-order-user-email
+24
ls-order-currency
+25
ls-order-subtotal
+26
ls-order-discount-total
+27
ls-order-tax
+28
ls-order-total
+29
ls-order-total-formatted
+30
ls-order-status
+31
ls-order-refunded
+32
ls-order-test-mode
+33
ls-order-created-at
+34
ls-order-updated-at
+35
+36
;; Order item records
+37
ls-order-item
+38
ls-order-item?
+39
ls-order-item-id
+40
ls-order-item-order-id
+41
ls-order-item-product-id
+42
ls-order-item-variant-id
+43
ls-order-item-product-name
+44
ls-order-item-variant-name
+45
ls-order-item-price
+46
ls-order-item-quantity
+47
ls-order-item-created-at
+48
ls-order-item-updated-at
+49
+50
;; Subscription records
+51
ls-subscription
+52
ls-subscription?
+53
ls-subscription-id
+54
ls-subscription-store-id
+55
ls-subscription-customer-id
+56
ls-subscription-order-id
+57
ls-subscription-product-id
+58
ls-subscription-variant-id
+59
ls-subscription-product-name
+60
ls-subscription-variant-name
+61
ls-subscription-user-name
+62
ls-subscription-user-email
+63
ls-subscription-status
+64
ls-subscription-card-brand
+65
ls-subscription-card-last-four
+66
ls-subscription-trial-ends-at
+67
ls-subscription-renews-at
+68
ls-subscription-ends-at
+69
ls-subscription-cancelled
+70
ls-subscription-test-mode
+71
ls-subscription-created-at
+72
ls-subscription-updated-at
+73
ls-subscription-urls
+74
+75
;; Parsing
+76
parse-order
+77
parse-order-item
+78
parse-subscription
+79
+80
;; Order API functions
+81
ls-orders
+82
ls-order-get
+83
ls-order-items
+84
ls-order-item-get
+85
+86
;; Subscription API functions
+87
ls-subscriptions
+88
ls-subscription-get
+89
ls-subscription-update
+90
ls-subscription-cancel)
+91
+92
(begin
+93
+94
;; ---------------------------------------------------------------
+95
;; Records
+96
;; ---------------------------------------------------------------
+97
+98
(define-struct ls-order
+99
(id)
+100
(store-id default: #f)
+101
(customer-id default: #f)
+102
(identifier default: "")
+103
(order-number default: 0)
+104
(user-name default: "")
+105
(user-email default: "")
+106
(currency default: "USD")
+107
(subtotal default: 0)
+108
(discount-total default: 0)
+109
(tax default: 0)
+110
(total default: 0)
+111
(total-formatted default: "")
+112
(status default: "pending")
+113
(refunded default: #f)
+114
(test-mode default: #f)
+115
(created-at default: #f)
+116
(updated-at default: #f))
+117
+118
(define-struct ls-order-item
+119
(id)
+120
(order-id default: #f)
+121
(product-id default: #f)
+122
(variant-id default: #f)
+123
(product-name default: "")
+124
(variant-name default: "")
+125
(price default: 0)
+126
(quantity default: 1)
+127
(created-at default: #f)
+128
(updated-at default: #f))
+129
+130
(define-struct ls-subscription
+131
(id)
+132
(store-id default: #f)
+133
(customer-id default: #f)
+134
(order-id default: #f)
+135
(product-id default: #f)
+136
(variant-id default: #f)
+137
(product-name default: "")
+138
(variant-name default: "")
+139
(user-name default: "")
+140
(user-email default: "")
+141
(status default: "active")
+142
(card-brand default: #f)
+143
(card-last-four default: #f)
+144
(trial-ends-at default: #f)
+145
(renews-at default: #f)
+146
(ends-at default: #f)
+147
(cancelled default: #f)
+148
(test-mode default: #f)
+149
(created-at default: #f)
+150
(updated-at default: #f)
+151
(urls default: #{}))
+152
+153
;; ---------------------------------------------------------------
+154
;; Parsing
+155
;; ---------------------------------------------------------------
+156
+157
;;; Parse a JSON:API order resource into an ls-order record.
+158
(define (parse-order resource)
+159
(ls-order
+160
id: (jsonapi-id resource)
+161
store-id: (jsonapi-attr resource store_id: #f)
+162
customer-id: (jsonapi-attr resource customer_id: #f)
+163
identifier: (jsonapi-attr resource identifier: "")
+164
order-number: (jsonapi-attr resource order_number: 0)
+165
user-name: (jsonapi-attr resource user_name: "")
+166
user-email: (jsonapi-attr resource user_email: "")
+167
currency: (jsonapi-attr resource currency: "USD")
+168
subtotal: (jsonapi-attr resource subtotal: 0)
+169
discount-total: (jsonapi-attr resource discount_total: 0)
+170
tax: (jsonapi-attr resource tax: 0)
+171
total: (jsonapi-attr resource total: 0)
+172
total-formatted: (jsonapi-attr resource total_formatted: "")
+173
status: (jsonapi-attr resource status: "pending")
+174
refunded: (jsonapi-attr resource refunded: #f)
+175
test-mode: (jsonapi-attr resource test_mode: #f)
+176
created-at: (jsonapi-attr resource created_at: #f)
+177
updated-at: (jsonapi-attr resource updated_at: #f)))
+178
+179
;;; Parse a JSON:API order item resource into an ls-order-item record.
+180
(define (parse-order-item resource)
+181
(ls-order-item
+182
id: (jsonapi-id resource)
+183
order-id: (jsonapi-attr resource order_id: #f)
+184
product-id: (jsonapi-attr resource product_id: #f)
+185
variant-id: (jsonapi-attr resource variant_id: #f)
+186
product-name: (jsonapi-attr resource product_name: "")
+187
variant-name: (jsonapi-attr resource variant_name: "")
+188
price: (jsonapi-attr resource price: 0)
+189
quantity: (jsonapi-attr resource quantity: 1)
+190
created-at: (jsonapi-attr resource created_at: #f)
+191
updated-at: (jsonapi-attr resource updated_at: #f)))
+192
+193
;;; Parse a JSON:API subscription resource into an ls-subscription record.
+194
(define (parse-subscription resource)
+195
(ls-subscription
+196
id: (jsonapi-id resource)
+197
store-id: (jsonapi-attr resource store_id: #f)
+198
customer-id: (jsonapi-attr resource customer_id: #f)
+199
order-id: (jsonapi-attr resource order_id: #f)
+200
product-id: (jsonapi-attr resource product_id: #f)
+201
variant-id: (jsonapi-attr resource variant_id: #f)
+202
product-name: (jsonapi-attr resource product_name: "")
+203
variant-name: (jsonapi-attr resource variant_name: "")
+204
user-name: (jsonapi-attr resource user_name: "")
+205
user-email: (jsonapi-attr resource user_email: "")
+206
status: (jsonapi-attr resource status: "active")
+207
card-brand: (jsonapi-attr resource card_brand: #f)
+208
card-last-four: (jsonapi-attr resource card_last_four: #f)
+209
trial-ends-at: (jsonapi-attr resource trial_ends_at: #f)
+210
renews-at: (jsonapi-attr resource renews_at: #f)
+211
ends-at: (jsonapi-attr resource ends_at: #f)
+212
cancelled: (jsonapi-attr resource cancelled: #f)
+213
test-mode: (jsonapi-attr resource test_mode: #f)
+214
created-at: (jsonapi-attr resource created_at: #f)
+215
updated-at: (jsonapi-attr resource updated_at: #f)
+216
urls: (let ((attrs (jsonapi-attrs resource)))
+217
(dict-ref attrs urls: #{}))))
+218
+219
;; ---------------------------------------------------------------
+220
;; Order API functions
+221
;; ---------------------------------------------------------------
+222
+223
;;; List orders with optional filtering and pagination.
+224
(define (ls-orders client . rest)
+225
(apply ls-list-endpoint client parse-order '("v1" "orders") rest))
+226
+227
;;; Get a single order by ID.
+228
(define (ls-order-get client order-id)
+229
(ls-get-endpoint client parse-order '("v1" "orders") order-id))
+230
+231
;;; List order items with optional filtering.
+232
(define (ls-order-items client . rest)
+233
(apply ls-list-endpoint client parse-order-item '("v1" "order-items") rest))
+234
+235
;;; Get a single order item by ID.
+236
(define (ls-order-item-get client order-item-id)
+237
(ls-get-endpoint client parse-order-item '("v1" "order-items") order-item-id))
+238
+239
;; ---------------------------------------------------------------
+240
;; Subscription API functions
+241
;; ---------------------------------------------------------------
+242
+243
;;; List subscriptions with optional filtering and pagination.
+244
(define (ls-subscriptions client . rest)
+245
(apply ls-list-endpoint client parse-subscription '("v1" "subscriptions") rest))
+246
+247
;;; Get a single subscription by ID.
+248
(define (ls-subscription-get client subscription-id)
+249
(ls-get-endpoint client parse-subscription '("v1" "subscriptions") subscription-id))
+250
+251
;;; Update a subscription.
+252
;;; updates is a dict of attributes to change (e.g., variant_id:, billing_anchor:).
+253
;;; Returns an ls-subscription record.
+254
(define (ls-subscription-update client subscription-id updates)
+255
(let* ((body #{ data:
+256
#{ type: "subscriptions"
+257
id: subscription-id
+258
attributes: updates } })
+259
(url (ls-api-url client "v1" "subscriptions" subscription-id))
+260
(response (ls-patch/json client url body)))
+261
(parse-subscription (jsonapi-data response))))
+262
+263
;;; Cancel a subscription.
+264
;;; Sets status to "cancelled"; access continues until ends_at.
+265
;;; Returns an ls-subscription record.
+266
(define (ls-subscription-cancel client subscription-id)
+267
(let* ((url (ls-api-url client "v1" "subscriptions" subscription-id))
+268
(response (ls-delete/json client url)))
+269
(if (and response (dict? response))
+270
(parse-subscription (jsonapi-data response))
+271
#t)))
+272
+273
))
src/sigil/lemonsqueezy/product.sgladded
@@ -0,0 +1,147 @@
+1
;;; (sigil lemonsqueezy product) - Product and Variant management.
+2
;;;
+3
;;; Products and variants are read-only via the API (created in dashboard).
+4
;;; Products describe digital goods; variants represent purchasable
+5
;;; configurations (tiers, plans).
+6
+7
(define-library (sigil lemonsqueezy product)
+8
(import (sigil core)
+9
(sigil dict)
+10
(sigil string)
+11
(sigil struct)
+12
(sigil lemonsqueezy))
+13
+14
(export ;; Records
+15
ls-product
+16
ls-product?
+17
ls-product-id
+18
ls-product-name
+19
ls-product-slug
+20
ls-product-description
+21
ls-product-status
+22
ls-product-price
+23
ls-product-price-formatted
+24
ls-product-buy-now-url
+25
ls-product-store-id
+26
ls-product-test-mode
+27
ls-product-created-at
+28
ls-product-updated-at
+29
+30
ls-variant
+31
ls-variant?
+32
ls-variant-id
+33
ls-variant-name
+34
ls-variant-slug
+35
ls-variant-description
+36
ls-variant-price
+37
ls-variant-sort
+38
ls-variant-status
+39
ls-variant-product-id
+40
ls-variant-has-license-keys
+41
ls-variant-license-activation-limit
+42
ls-variant-created-at
+43
ls-variant-updated-at
+44
+45
;; Parsing
+46
parse-product
+47
parse-variant
+48
+49
;; API functions
+50
ls-products
+51
ls-product-get
+52
ls-variants
+53
ls-variant-get)
+54
+55
(begin
+56
+57
;; ---------------------------------------------------------------
+58
;; Records
+59
;; ---------------------------------------------------------------
+60
+61
(define-struct ls-product
+62
(id)
+63
(name default: "")
+64
(slug default: "")
+65
(description default: "")
+66
(status default: "draft")
+67
(price default: 0)
+68
(price-formatted default: "")
+69
(buy-now-url default: #f)
+70
(store-id default: #f)
+71
(test-mode default: #f)
+72
(created-at default: #f)
+73
(updated-at default: #f))
+74
+75
(define-struct ls-variant
+76
(id)
+77
(name default: "")
+78
(slug default: "")
+79
(description default: "")
+80
(price default: 0)
+81
(sort default: 0)
+82
(status default: "pending")
+83
(product-id default: #f)
+84
(has-license-keys default: #f)
+85
(license-activation-limit default: 0)
+86
(created-at default: #f)
+87
(updated-at default: #f))
+88
+89
;; ---------------------------------------------------------------
+90
;; Parsing
+91
;; ---------------------------------------------------------------
+92
+93
;;; Parse a JSON:API product resource into an ls-product record.
+94
(define (parse-product resource)
+95
(ls-product
+96
id: (jsonapi-id resource)
+97
name: (jsonapi-attr resource name: "")
+98
slug: (jsonapi-attr resource slug: "")
+99
description: (jsonapi-attr resource description: "")
+100
status: (jsonapi-attr resource status: "draft")
+101
price: (jsonapi-attr resource price: 0)
+102
price-formatted: (jsonapi-attr resource price_formatted: "")
+103
buy-now-url: (jsonapi-attr resource buy_now_url: #f)
+104
store-id: (jsonapi-attr resource store_id: #f)
+105
test-mode: (jsonapi-attr resource test_mode: #f)
+106
created-at: (jsonapi-attr resource created_at: #f)
+107
updated-at: (jsonapi-attr resource updated_at: #f)))
+108
+109
;;; Parse a JSON:API variant resource into an ls-variant record.
+110
(define (parse-variant resource)
+111
(ls-variant
+112
id: (jsonapi-id resource)
+113
name: (jsonapi-attr resource name: "")
+114
slug: (jsonapi-attr resource slug: "")
+115
description: (jsonapi-attr resource description: "")
+116
price: (jsonapi-attr resource price: 0)
+117
sort: (jsonapi-attr resource sort: 0)
+118
status: (jsonapi-attr resource status: "pending")
+119
product-id: (jsonapi-attr resource product_id: #f)
+120
has-license-keys: (jsonapi-attr resource has_license_keys: #f)
+121
license-activation-limit: (jsonapi-attr resource license_activation_limit: 0)
+122
created-at: (jsonapi-attr resource created_at: #f)
+123
updated-at: (jsonapi-attr resource updated_at: #f)))
+124
+125
;; ---------------------------------------------------------------
+126
;; API functions
+127
;; ---------------------------------------------------------------
+128
+129
;;; List products with optional filtering and pagination.
+130
;;; Returns a list of ls-product records.
+131
(define (ls-products client . rest)
+132
(apply ls-list-endpoint client parse-product '("v1" "products") rest))
+133
+134
;;; Get a single product by ID.
+135
(define (ls-product-get client product-id)
+136
(ls-get-endpoint client parse-product '("v1" "products") product-id))
+137
+138
;;; List variants with optional filtering and pagination.
+139
;;; Returns a list of ls-variant records.
+140
(define (ls-variants client . rest)
+141
(apply ls-list-endpoint client parse-variant '("v1" "variants") rest))
+142
+143
;;; Get a single variant by ID.
+144
(define (ls-variant-get client variant-id)
+145
(ls-get-endpoint client parse-variant '("v1" "variants") variant-id))
+146
+147
))
src/sigil/lemonsqueezy/webhook.sgladded
@@ -0,0 +1,191 @@
+1
;;; (sigil lemonsqueezy webhook) - Webhook management and verification.
+2
;;;
+3
;;; Handles HMAC-SHA256 signature verification of incoming webhooks,
+4
;;; parsing of webhook payloads, and CRUD operations for webhook endpoints.
+5
;;;
+6
;;; Webhook payloads use a modified JSON:API format with a top-level
+7
;;; meta field containing event_name and custom_data.
+8
+9
(define-library (sigil lemonsqueezy webhook)
+10
(import (sigil core)
+11
(sigil dict)
+12
(sigil string)
+13
(sigil struct)
+14
(sigil json)
+15
(sigil crypto)
+16
(sigil lemonsqueezy))
+17
+18
(export ;; Records
+19
ls-webhook
+20
ls-webhook?
+21
ls-webhook-id
+22
ls-webhook-url
+23
ls-webhook-events
+24
ls-webhook-store-id
+25
ls-webhook-test-mode
+26
ls-webhook-created-at
+27
ls-webhook-updated-at
+28
+29
ls-webhook-event
+30
ls-webhook-event?
+31
ls-webhook-event-name
+32
ls-webhook-event-data
+33
ls-webhook-event-custom-data
+34
ls-webhook-event-meta
+35
+36
;; Parsing
+37
parse-webhook
+38
parse-webhook-event
+39
+40
;; Verification
+41
ls-verify-webhook
+42
+43
;; API functions
+44
ls-create-webhook
+45
ls-webhooks
+46
ls-webhook-get
+47
ls-webhook-update
+48
ls-webhook-delete)
+49
+50
(begin
+51
+52
;; ---------------------------------------------------------------
+53
;; Records
+54
;; ---------------------------------------------------------------
+55
+56
(define-struct ls-webhook
+57
(id)
+58
(url default: "")
+59
(events default: '())
+60
(store-id default: #f)
+61
(test-mode default: #f)
+62
(created-at default: #f)
+63
(updated-at default: #f))
+64
+65
(define-struct ls-webhook-event
+66
(name)
+67
(data default: #{})
+68
(custom-data default: #{})
+69
(meta default: #{}))
+70
+71
;; ---------------------------------------------------------------
+72
;; Parsing
+73
;; ---------------------------------------------------------------
+74
+75
;;; Parse a JSON:API webhook resource into an ls-webhook record.
+76
(define (parse-webhook resource)
+77
(let ((events-raw (jsonapi-attr resource events: #f)))
+78
(ls-webhook
+79
id: (jsonapi-id resource)
+80
url: (jsonapi-attr resource url: "")
+81
events: (if (and events-raw (array? events-raw))
+82
(array->list events-raw)
+83
(if (and events-raw (list? events-raw))
+84
events-raw
+85
'()))
+86
store-id: (jsonapi-attr resource store_id: #f)
+87
test-mode: (jsonapi-attr resource test_mode: #f)
+88
created-at: (jsonapi-attr resource created_at: #f)
+89
updated-at: (jsonapi-attr resource updated_at: #f))))
+90
+91
;;; Parse a raw webhook payload (as a JSON string or parsed dict)
+92
;;; into an ls-webhook-event record.
+93
;;;
+94
;;; The payload has the structure:
+95
;;; { meta: { event_name, custom_data }, data: { type, id, attributes, ... } }
+96
(define (parse-webhook-event payload)
+97
(let* ((parsed (if (string? payload) (json-decode payload) payload))
+98
(meta (dict-ref parsed meta: #{}))
+99
(event-name (dict-ref meta event_name: ""))
+100
(custom-data (let ((cd (dict-ref meta custom_data: #f)))
+101
(if (or (not cd) (eq? cd 'null)) #{} cd)))
+102
(data (dict-ref parsed data: #{})))
+103
(ls-webhook-event
+104
name: event-name
+105
data: data
+106
custom-data: custom-data
+107
meta: meta)))
+108
+109
;; ---------------------------------------------------------------
+110
;; Verification
+111
;; ---------------------------------------------------------------
+112
+113
;;; Verify a webhook signature using HMAC-SHA256.
+114
;;;
+115
;;; secret: the webhook signing secret (string)
+116
;;; raw-body: the raw request body (string, NOT parsed JSON)
+117
;;; signature: the X-Signature header value (hex string)
+118
;;;
+119
;;; Returns #t if the signature is valid, #f otherwise.
+120
;;; Uses timing-safe comparison to prevent timing attacks.
+121
(define (ls-verify-webhook secret raw-body signature)
+122
(let ((computed (hmac-sha256 secret raw-body)))
+123
(timing-safe-equal? computed signature)))
+124
+125
;;; Timing-safe string comparison to prevent timing attacks.
+126
;;; Compares every character regardless of mismatches.
+127
(define (timing-safe-equal? a b)
+128
(if (not (= (string-length a) (string-length b)))
+129
#f
+130
(let loop ((i 0) (diff 0))
+131
(if (>= i (string-length a))
+132
(= diff 0)
+133
(loop (+ i 1)
+134
(+ diff
+135
(if (char=? (string-ref a i)
+136
(string-ref b i))
+137
0 1)))))))
+138
+139
;; ---------------------------------------------------------------
+140
;; API functions
+141
;; ---------------------------------------------------------------
+142
+143
;;; Create a webhook endpoint.
+144
;;;
+145
;;; store-id: the store ID (string)
+146
;;; url: the webhook URL to receive events
+147
;;; events: list of event name strings (e.g., '("order_created" "subscription_created"))
+148
;;; secret: signing secret for HMAC verification (6-40 chars recommended)
+149
;;;
+150
;;; Returns an ls-webhook record.
+151
(define (ls-create-webhook client store-id webhook-url events secret)
+152
(let* ((body #{ data:
+153
#{ type: "webhooks"
+154
attributes:
+155
#{ url: webhook-url
+156
events: (list->array events)
+157
secret: secret }
+158
relationships:
+159
#{ store:
+160
#{ data: #{ type: "stores" id: store-id } } } } })
+161
(api-url (ls-api-url client "v1" "webhooks"))
+162
(response (ls-post/json client api-url body)))
+163
(parse-webhook (jsonapi-data response))))
+164
+165
;;; List webhooks with optional filtering and pagination.
+166
(define (ls-webhooks client . rest)
+167
(apply ls-list-endpoint client parse-webhook '("v1" "webhooks") rest))
+168
+169
;;; Get a single webhook by ID.
+170
(define (ls-webhook-get client webhook-id)
+171
(ls-get-endpoint client parse-webhook '("v1" "webhooks") webhook-id))
+172
+173
;;; Update a webhook.
+174
;;; updates is a dict of attributes to change (e.g., url:, events:, secret:).
+175
;;; Returns an ls-webhook record.
+176
(define (ls-webhook-update client webhook-id updates)
+177
(let* ((body #{ data:
+178
#{ type: "webhooks"
+179
id: webhook-id
+180
attributes: updates } })
+181
(url (ls-api-url client "v1" "webhooks" webhook-id))
+182
(response (ls-patch/json client url body)))
+183
(parse-webhook (jsonapi-data response))))
+184
+185
;;; Delete a webhook by ID.
+186
(define (ls-webhook-delete client webhook-id)
+187
(let ((url (ls-api-url client "v1" "webhooks" webhook-id)))
+188
(ls-delete/json client url)
+189
#t))
+190
+191
))
test/lemonsqueezy-test.sgladded
@@ -0,0 +1,683 @@
+1
;;; Tests for sigil-lemonsqueezy
+2
;;;
+3
;;; Tests JSON:API parsing, record construction, webhook verification,
+4
;;; and query parameter building using response fixtures.
+5
+6
(import (sigil core)
+7
(sigil dict)
+8
(sigil string)
+9
(sigil struct)
+10
(sigil json)
+11
(sigil crypto)
+12
(sigil test)
+13
(sigil lemonsqueezy)
+14
(sigil lemonsqueezy product)
+15
(sigil lemonsqueezy checkout)
+16
(sigil lemonsqueezy order)
+17
(sigil lemonsqueezy webhook)
+18
(sigil lemonsqueezy license))
+19
+20
;; ---------------------------------------------------------------
+21
;; Test fixtures — JSON:API response samples
+22
;; ---------------------------------------------------------------
+23
+24
(define product-response-json
+25
(json-decode
+26
(string-append
+27
"{\"data\": {\"type\": \"products\", \"id\": \"1\","
+28
" \"attributes\": {"
+29
" \"store_id\": 1,"
+30
" \"name\": \"Course Bundle\","
+31
" \"slug\": \"course-bundle\","
+32
" \"description\": \"<p>All courses</p>\","
+33
" \"status\": \"published\","
+34
" \"price\": 9999,"
+35
" \"price_formatted\": \"$99.99\","
+36
" \"buy_now_url\": \"https://store.example.com/buy/1\","
+37
" \"test_mode\": false,"
+38
" \"created_at\": \"2026-01-15T10:30:00.000000Z\","
+39
" \"updated_at\": \"2026-03-01T08:00:00.000000Z\""
+40
" },"
+41
" \"relationships\": {"
+42
" \"store\": {\"data\": {\"type\": \"stores\", \"id\": \"1\"}},"
+43
" \"variants\": {\"data\": ["
+44
" {\"type\": \"variants\", \"id\": \"10\"},"
+45
" {\"type\": \"variants\", \"id\": \"11\"}"
+46
" ]}"
+47
" }}}")))
+48
+49
(define products-list-json
+50
(json-decode
+51
(string-append
+52
"{\"data\": ["
+53
" {\"type\": \"products\", \"id\": \"1\","
+54
" \"attributes\": {\"name\": \"Course A\", \"status\": \"published\","
+55
" \"price\": 4999, \"price_formatted\": \"$49.99\","
+56
" \"store_id\": 1, \"test_mode\": false,"
+57
" \"created_at\": \"2026-01-01T00:00:00.000000Z\","
+58
" \"updated_at\": \"2026-01-01T00:00:00.000000Z\"}},"
+59
" {\"type\": \"products\", \"id\": \"2\","
+60
" \"attributes\": {\"name\": \"Course B\", \"status\": \"draft\","
+61
" \"price\": 2999, \"price_formatted\": \"$29.99\","
+62
" \"store_id\": 1, \"test_mode\": true,"
+63
" \"created_at\": \"2026-02-01T00:00:00.000000Z\","
+64
" \"updated_at\": \"2026-02-01T00:00:00.000000Z\"}}"
+65
"],"
+66
"\"meta\": {\"page\": {\"currentPage\": 1, \"lastPage\": 1,"
+67
" \"perPage\": 10, \"total\": 2, \"from\": 1, \"to\": 2}},"
+68
"\"links\": {\"first\": \"https://api.lemonsqueezy.com/v1/products?page%5Bnumber%5D=1\","
+69
" \"last\": \"https://api.lemonsqueezy.com/v1/products?page%5Bnumber%5D=1\"}}")))
+70
+71
(define variant-response-json
+72
(json-decode
+73
(string-append
+74
"{\"data\": {\"type\": \"variants\", \"id\": \"10\","
+75
" \"attributes\": {"
+76
" \"product_id\": 1,"
+77
" \"name\": \"Standard\","
+78
" \"slug\": \"standard\","
+79
" \"description\": \"Standard tier\","
+80
" \"price\": 4999,"
+81
" \"sort\": 1,"
+82
" \"status\": \"published\","
+83
" \"has_license_keys\": true,"
+84
" \"license_activation_limit\": 3,"
+85
" \"created_at\": \"2026-01-15T10:30:00.000000Z\","
+86
" \"updated_at\": \"2026-03-01T08:00:00.000000Z\""
+87
" }}}")))
+88
+89
(define order-response-json
+90
(json-decode
+91
(string-append
+92
"{\"data\": {\"type\": \"orders\", \"id\": \"100\","
+93
" \"attributes\": {"
+94
" \"store_id\": 1,"
+95
" \"customer_id\": 50,"
+96
" \"identifier\": \"a1b2c3d4-e5f6-7890-abcd-ef1234567890\","
+97
" \"order_number\": 42,"
+98
" \"user_name\": \"Alice Smith\","
+99
" \"user_email\": \"[email protected]\","
+100
" \"currency\": \"USD\","
+101
" \"subtotal\": 9999,"
+102
" \"discount_total\": 0,"
+103
" \"tax\": 850,"
+104
" \"total\": 10849,"
+105
" \"total_formatted\": \"$108.49\","
+106
" \"status\": \"paid\","
+107
" \"refunded\": false,"
+108
" \"test_mode\": false,"
+109
" \"created_at\": \"2026-03-20T14:00:00.000000Z\","
+110
" \"updated_at\": \"2026-03-20T14:00:00.000000Z\""
+111
" }}}")))
+112
+113
(define order-item-json
+114
(json-decode
+115
(string-append
+116
"{\"data\": {\"type\": \"order-items\", \"id\": \"200\","
+117
" \"attributes\": {"
+118
" \"order_id\": 100,"
+119
" \"product_id\": 1,"
+120
" \"variant_id\": 10,"
+121
" \"product_name\": \"Course Bundle\","
+122
" \"variant_name\": \"Standard\","
+123
" \"price\": 9999,"
+124
" \"quantity\": 1,"
+125
" \"created_at\": \"2026-03-20T14:00:00.000000Z\","
+126
" \"updated_at\": \"2026-03-20T14:00:00.000000Z\""
+127
" }}}")))
+128
+129
(define subscription-response-json
+130
(json-decode
+131
(string-append
+132
"{\"data\": {\"type\": \"subscriptions\", \"id\": \"300\","
+133
" \"attributes\": {"
+134
" \"store_id\": 1,"
+135
" \"customer_id\": 50,"
+136
" \"order_id\": 100,"
+137
" \"product_id\": 1,"
+138
" \"variant_id\": 10,"
+139
" \"product_name\": \"Pro Plan\","
+140
" \"variant_name\": \"Monthly\","
+141
" \"user_name\": \"Alice Smith\","
+142
" \"user_email\": \"[email protected]\","
+143
" \"status\": \"active\","
+144
" \"card_brand\": \"visa\","
+145
" \"card_last_four\": \"4242\","
+146
" \"trial_ends_at\": null,"
+147
" \"renews_at\": \"2026-04-20T14:00:00.000000Z\","
+148
" \"ends_at\": null,"
+149
" \"cancelled\": false,"
+150
" \"test_mode\": false,"
+151
" \"created_at\": \"2026-03-20T14:00:00.000000Z\","
+152
" \"updated_at\": \"2026-03-20T14:00:00.000000Z\","
+153
" \"urls\": {"
+154
" \"update_payment_method\": \"https://example.lemonsqueezy.com/billing\","
+155
" \"customer_portal\": \"https://example.lemonsqueezy.com/portal\""
+156
" }"
+157
" }}}")))
+158
+159
(define checkout-response-json
+160
(json-decode
+161
(string-append
+162
"{\"data\": {\"type\": \"checkouts\", \"id\": \"ch_abc123\","
+163
" \"attributes\": {"
+164
" \"store_id\": 1,"
+165
" \"variant_id\": 10,"
+166
" \"url\": \"https://example.lemonsqueezy.com/checkout/buy/abc123\","
+167
" \"custom_price\": null,"
+168
" \"expires_at\": \"2026-04-01T00:00:00.000000Z\","
+169
" \"created_at\": \"2026-03-25T10:00:00.000000Z\","
+170
" \"updated_at\": \"2026-03-25T10:00:00.000000Z\","
+171
" \"test_mode\": true"
+172
" }}}")))
+173
+174
(define webhook-resource-json
+175
(json-decode
+176
(string-append
+177
"{\"data\": {\"type\": \"webhooks\", \"id\": \"500\","
+178
" \"attributes\": {"
+179
" \"store_id\": 1,"
+180
" \"url\": \"https://myapp.example.com/webhooks/ls\","
+181
" \"events\": [\"order_created\", \"subscription_created\","
+182
" \"subscription_expired\"],"
+183
" \"test_mode\": false,"
+184
" \"created_at\": \"2026-03-20T10:00:00.000000Z\","
+185
" \"updated_at\": \"2026-03-20T10:00:00.000000Z\""
+186
" }}}")))
+187
+188
(define webhook-payload-json
+189
(string-append
+190
"{\"meta\": {"
+191
" \"event_name\": \"order_created\","
+192
" \"custom_data\": {\"user_id\": \"123\", \"plan\": \"pro\"}"
+193
"},"
+194
"\"data\": {"
+195
" \"type\": \"orders\","
+196
" \"id\": \"100\","
+197
" \"attributes\": {"
+198
" \"store_id\": 1,"
+199
" \"user_email\": \"[email protected]\","
+200
" \"status\": \"paid\","
+201
" \"total\": 9999"
+202
" }"
+203
"}}"))
+204
+205
(define license-key-json
+206
(json-decode
+207
(string-append
+208
"{\"data\": {\"type\": \"license-keys\", \"id\": \"700\","
+209
" \"attributes\": {"
+210
" \"store_id\": 1,"
+211
" \"customer_id\": 50,"
+212
" \"order_id\": 100,"
+213
" \"product_id\": 1,"
+214
" \"key\": \"38b1460a-5104-4067-a91d-77b872934d51\","
+215
" \"key_short\": \"XXXX-72934d51\","
+216
" \"activation_limit\": 3,"
+217
" \"instances_count\": 1,"
+218
" \"disabled\": false,"
+219
" \"status\": \"active\","
+220
" \"expires_at\": null,"
+221
" \"created_at\": \"2026-03-20T14:00:00.000000Z\","
+222
" \"updated_at\": \"2026-03-20T14:00:00.000000Z\""
+223
" }}}")))
+224
+225
(define license-validation-json
+226
(json-decode
+227
(string-append
+228
"{\"valid\": true, \"error\": null,"
+229
" \"license_key\": {"
+230
" \"id\": 700,"
+231
" \"status\": \"active\","
+232
" \"key\": \"38b1460a-5104-4067-a91d-77b872934d51\","
+233
" \"activation_limit\": 3,"
+234
" \"activation_usage\": 1"
+235
" },"
+236
" \"instance\": {"
+237
" \"id\": \"f90ec370-1234-5678-9abc-def012345678\","
+238
" \"name\": \"mysite.com\","
+239
" \"created_at\": \"2026-03-20T14:00:00.000000Z\""
+240
" },"
+241
" \"meta\": {"
+242
" \"store_id\": 1,"
+243
" \"order_id\": 100,"
+244
" \"product_id\": 1,"
+245
" \"variant_id\": 10,"
+246
" \"product_name\": \"Course Bundle\","
+247
" \"variant_name\": \"Standard\","
+248
" \"customer_name\": \"Alice Smith\","
+249
" \"customer_email\": \"[email protected]\""
+250
" }}")))
+251
+252
(define included-response-json
+253
(json-decode
+254
(string-append
+255
"{\"data\": {\"type\": \"products\", \"id\": \"1\","
+256
" \"attributes\": {\"name\": \"Course Bundle\"},"
+257
" \"relationships\": {"
+258
" \"variants\": {\"data\": ["
+259
" {\"type\": \"variants\", \"id\": \"10\"},"
+260
" {\"type\": \"variants\", \"id\": \"11\"}"
+261
" ]}"
+262
" }},"
+263
"\"included\": ["
+264
" {\"type\": \"variants\", \"id\": \"10\","
+265
" \"attributes\": {\"name\": \"Standard\", \"price\": 4999}},"
+266
" {\"type\": \"variants\", \"id\": \"11\","
+267
" \"attributes\": {\"name\": \"Premium\", \"price\": 9999}}"
+268
"]}")))
+269
+270
;; ---------------------------------------------------------------
+271
;; JSON:API helper tests
+272
;; ---------------------------------------------------------------
+273
+274
(test-group "JSON:API helpers"
+275
+276
(test "jsonapi-id extracts resource id"
+277
(let ((data (jsonapi-data product-response-json)))
+278
(assert-equal "1" (jsonapi-id data))))
+279
+280
(test "jsonapi-type extracts resource type"
+281
(let ((data (jsonapi-data product-response-json)))
+282
(assert-equal "products" (jsonapi-type data))))
+283
+284
(test "jsonapi-attr extracts attribute"
+285
(let ((data (jsonapi-data product-response-json)))
+286
(assert-equal "Course Bundle" (jsonapi-attr data name:))
+287
(assert-equal 9999 (jsonapi-attr data price:))
+288
(assert-equal "published" (jsonapi-attr data status:))))
+289
+290
(test "jsonapi-attr returns default for missing attribute"
+291
(let ((data (jsonapi-data product-response-json)))
+292
(assert-equal "default" (jsonapi-attr data nonexistent: "default"))))
+293
+294
(test "jsonapi-attrs returns full attributes dict"
+295
(let ((attrs (jsonapi-attrs (jsonapi-data product-response-json))))
+296
(assert-true (dict? attrs))
+297
(assert-equal "Course Bundle" (dict-ref attrs name:))))
+298
+299
(test "jsonapi-relationship-id extracts single relationship"
+300
(let ((data (jsonapi-data product-response-json)))
+301
(assert-equal "1" (jsonapi-relationship-id data store:))))
+302
+303
(test "jsonapi-relationship-ids extracts has-many relationship"
+304
(let ((data (jsonapi-data product-response-json)))
+305
(let ((ids (jsonapi-relationship-ids data variants:)))
+306
(assert-equal 2 (length ids))
+307
(assert-equal "10" (car ids))
+308
(assert-equal "11" (cadr ids)))))
+309
+310
(test "jsonapi-data-list extracts list from array data"
+311
(let ((items (jsonapi-data-list products-list-json)))
+312
(assert-equal 2 (length items))
+313
(assert-equal "1" (jsonapi-id (car items)))
+314
(assert-equal "2" (jsonapi-id (cadr items)))))
+315
+316
(test "jsonapi-included extracts included resources"
+317
(let ((included (jsonapi-included included-response-json)))
+318
(assert-equal 2 (length included))
+319
(assert-equal "variants" (jsonapi-type (car included)))))
+320
+321
(test "jsonapi-find-included finds by type and id"
+322
(let ((variant (jsonapi-find-included included-response-json "variants" "11")))
+323
(assert-true (dict? variant))
+324
(assert-equal "11" (jsonapi-id variant))
+325
(assert-equal "Premium" (jsonapi-attr variant name:))))
+326
+327
(test "jsonapi-find-included returns #f for missing"
+328
(assert-equal #f (jsonapi-find-included included-response-json "variants" "999")))
+329
+330
(test "jsonapi-pagination-meta extracts page info"
+331
(let ((meta (jsonapi-pagination-meta products-list-json)))
+332
(assert-equal 1 (dict-ref meta current-page:))
+333
(assert-equal 1 (dict-ref meta last-page:))
+334
(assert-equal 10 (dict-ref meta per-page:))
+335
(assert-equal 2 (dict-ref meta total:)))))
+336
+337
;; ---------------------------------------------------------------
+338
;; maybe-null tests
+339
;; ---------------------------------------------------------------
+340
+341
(test-group "maybe-null"
+342
+343
(test "normalizes null symbol to #f"
+344
(assert-equal #f (maybe-null 'null)))
+345
+346
(test "passes through #f"
+347
(assert-equal #f (maybe-null #f)))
+348
+349
(test "passes through truthy values"
+350
(assert-equal "hello" (maybe-null "hello"))
+351
(assert-equal 42 (maybe-null 42))))
+352
+353
;; ---------------------------------------------------------------
+354
;; Product parsing tests
+355
;; ---------------------------------------------------------------
+356
+357
(test-group "product parsing"
+358
+359
(test "parse single product"
+360
(let ((p (parse-product (jsonapi-data product-response-json))))
+361
(assert-true (ls-product? p))
+362
(assert-equal "1" (ls-product-id p))
+363
(assert-equal "Course Bundle" (ls-product-name p))
+364
(assert-equal "course-bundle" (ls-product-slug p))
+365
(assert-equal "published" (ls-product-status p))
+366
(assert-equal 9999 (ls-product-price p))
+367
(assert-equal "$99.99" (ls-product-price-formatted p))
+368
(assert-equal "https://store.example.com/buy/1" (ls-product-buy-now-url p))))
+369
+370
(test "parse product list"
+371
(let ((products (map parse-product (jsonapi-data-list products-list-json))))
+372
(assert-equal 2 (length products))
+373
(assert-equal "Course A" (ls-product-name (car products)))
+374
(assert-equal "Course B" (ls-product-name (cadr products)))
+375
(assert-equal "draft" (ls-product-status (cadr products))))))
+376
+377
;; ---------------------------------------------------------------
+378
;; Variant parsing tests
+379
;; ---------------------------------------------------------------
+380
+381
(test-group "variant parsing"
+382
+383
(test "parse single variant"
+384
(let ((v (parse-variant (jsonapi-data variant-response-json))))
+385
(assert-true (ls-variant? v))
+386
(assert-equal "10" (ls-variant-id v))
+387
(assert-equal "Standard" (ls-variant-name v))
+388
(assert-equal "standard" (ls-variant-slug v))
+389
(assert-equal 4999 (ls-variant-price v))
+390
(assert-equal 1 (ls-variant-sort v))
+391
(assert-equal "published" (ls-variant-status v))
+392
(assert-equal 1 (ls-variant-product-id v))
+393
(assert-equal #t (ls-variant-has-license-keys v))
+394
(assert-equal 3 (ls-variant-license-activation-limit v)))))
+395
+396
;; ---------------------------------------------------------------
+397
;; Order parsing tests
+398
;; ---------------------------------------------------------------
+399
+400
(test-group "order parsing"
+401
+402
(test "parse single order"
+403
(let ((o (parse-order (jsonapi-data order-response-json))))
+404
(assert-true (ls-order? o))
+405
(assert-equal "100" (ls-order-id o))
+406
(assert-equal 1 (ls-order-store-id o))
+407
(assert-equal 50 (ls-order-customer-id o))
+408
(assert-equal "Alice Smith" (ls-order-user-name o))
+409
(assert-equal "[email protected]" (ls-order-user-email o))
+410
(assert-equal "USD" (ls-order-currency o))
+411
(assert-equal 10849 (ls-order-total o))
+412
(assert-equal "$108.49" (ls-order-total-formatted o))
+413
(assert-equal "paid" (ls-order-status o))
+414
(assert-equal #f (ls-order-refunded o)))))
+415
+416
;; ---------------------------------------------------------------
+417
;; Order item parsing tests
+418
;; ---------------------------------------------------------------
+419
+420
(test-group "order item parsing"
+421
+422
(test "parse order item"
+423
(let ((oi (parse-order-item (jsonapi-data order-item-json))))
+424
(assert-true (ls-order-item? oi))
+425
(assert-equal "200" (ls-order-item-id oi))
+426
(assert-equal 100 (ls-order-item-order-id oi))
+427
(assert-equal 1 (ls-order-item-product-id oi))
+428
(assert-equal 10 (ls-order-item-variant-id oi))
+429
(assert-equal "Course Bundle" (ls-order-item-product-name oi))
+430
(assert-equal "Standard" (ls-order-item-variant-name oi))
+431
(assert-equal 9999 (ls-order-item-price oi))
+432
(assert-equal 1 (ls-order-item-quantity oi)))))
+433
+434
;; ---------------------------------------------------------------
+435
;; Subscription parsing tests
+436
;; ---------------------------------------------------------------
+437
+438
(test-group "subscription parsing"
+439
+440
(test "parse subscription"
+441
(let ((s (parse-subscription (jsonapi-data subscription-response-json))))
+442
(assert-true (ls-subscription? s))
+443
(assert-equal "300" (ls-subscription-id s))
+444
(assert-equal "active" (ls-subscription-status s))
+445
(assert-equal "Pro Plan" (ls-subscription-product-name s))
+446
(assert-equal "Monthly" (ls-subscription-variant-name s))
+447
(assert-equal "Alice Smith" (ls-subscription-user-name s))
+448
(assert-equal "[email protected]" (ls-subscription-user-email s))
+449
(assert-equal "visa" (ls-subscription-card-brand s))
+450
(assert-equal "4242" (ls-subscription-card-last-four s))
+451
(assert-equal "2026-04-20T14:00:00.000000Z" (ls-subscription-renews-at s))
+452
(assert-equal #f (ls-subscription-cancelled s))))
+453
+454
(test "subscription null fields normalized"
+455
(let ((s (parse-subscription (jsonapi-data subscription-response-json))))
+456
;; trial_ends_at and ends_at are null in fixture
+457
(assert-equal #f (ls-subscription-trial-ends-at s))
+458
(assert-equal #f (ls-subscription-ends-at s))))
+459
+460
(test "subscription urls preserved"
+461
(let ((s (parse-subscription (jsonapi-data subscription-response-json))))
+462
(let ((urls (ls-subscription-urls s)))
+463
(assert-true (dict? urls))
+464
(assert-equal "https://example.lemonsqueezy.com/billing"
+465
(dict-ref urls update_payment_method:))
+466
(assert-equal "https://example.lemonsqueezy.com/portal"
+467
(dict-ref urls customer_portal:))))))
+468
+469
;; ---------------------------------------------------------------
+470
;; Checkout parsing tests
+471
;; ---------------------------------------------------------------
+472
+473
(test-group "checkout parsing"
+474
+475
(test "parse checkout"
+476
(let ((c (parse-checkout (jsonapi-data checkout-response-json))))
+477
(assert-true (ls-checkout? c))
+478
(assert-equal "ch_abc123" (ls-checkout-id c))
+479
(assert-equal "https://example.lemonsqueezy.com/checkout/buy/abc123"
+480
(ls-checkout-url c))
+481
(assert-equal 1 (ls-checkout-store-id c))
+482
(assert-equal 10 (ls-checkout-variant-id c))
+483
;; custom_price is null
+484
(assert-equal #f (ls-checkout-custom-price c))
+485
(assert-equal "2026-04-01T00:00:00.000000Z" (ls-checkout-expires-at c))
+486
(assert-equal #t (ls-checkout-test-mode c)))))
+487
+488
;; ---------------------------------------------------------------
+489
;; Webhook parsing tests
+490
;; ---------------------------------------------------------------
+491
+492
(test-group "webhook parsing"
+493
+494
(test "parse webhook resource"
+495
(let ((w (parse-webhook (jsonapi-data webhook-resource-json))))
+496
(assert-true (ls-webhook? w))
+497
(assert-equal "500" (ls-webhook-id w))
+498
(assert-equal "https://myapp.example.com/webhooks/ls" (ls-webhook-url w))
+499
(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.