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: "user@example.com"+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-Clausesrc/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\": \"alice@example.com\","+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\": \"alice@example.com\","+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\": \"alice@example.com\","+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\": \"alice@example.com\""+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 "alice@example.com" (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 "alice@example.com" (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.