Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
2a80e8b
Template for basic visuals of pay prompt
krestenlaust Mar 15, 2026
b207a78
Add POST '/api/sale/intent'-endpoint
krestenlaust Mar 17, 2026
73a79a6
fixup! Add POST '/api/sale/intent'-endpoint
krestenlaust Mar 17, 2026
79fc089
Clean-up templates
krestenlaust Mar 18, 2026
8c6865e
Also add default intent life span
krestenlaust Mar 19, 2026
299426a
Make webhook_url nullable in intent-model
krestenlaust Mar 19, 2026
38570f1
Adjust css values
krestenlaust Mar 21, 2026
f1a49a8
Refactor modal to separate base
krestenlaust Mar 21, 2026
66d3f98
Correct device scaling in modal and add favicon
krestenlaust Mar 21, 2026
8dcf1f1
Update common base with niceties
krestenlaust Mar 21, 2026
2965f0c
Rewrite pay-view to use locals()
krestenlaust Mar 21, 2026
eb1ea34
Rework payment form styling and backend logic
krestenlaust Mar 21, 2026
cb204fd
Black formatter
krestenlaust Mar 21, 2026
10f512a
Merge branch 'next' into feat/payment-intent
krestenlaust Mar 21, 2026
6b6cca7
Rename function + use intent_id parameter
krestenlaust Apr 3, 2026
8cd1b2a
Extract sale details dict into separate function
krestenlaust Apr 3, 2026
8d186b5
Test POST /api/sale/intent
krestenlaust Apr 3, 2026
a3e3584
Add redirect when pressing accept
krestenlaust Apr 4, 2026
df8eb6f
Add helper methods to Order class
krestenlaust Apr 4, 2026
60c1b76
Refactor Intent model fields
krestenlaust Apr 4, 2026
abc6cf6
fixup! Add helper methods to Order class
krestenlaust Apr 4, 2026
2b97573
fixup! Add redirect when pressing accept
krestenlaust Apr 4, 2026
9954b67
Add GET /api/sale/intent/<id> status endpoint
krestenlaust Apr 4, 2026
c68db86
fixup! Add GET /api/sale/intent/<id> status endpoint
krestenlaust Apr 4, 2026
8136415
fixup! Refactor Intent model fields
krestenlaust Apr 4, 2026
6d18255
Skip sale status endpoint test for now
krestenlaust Apr 4, 2026
f3e451f
Add logic for /accept and /reject
krestenlaust Apr 4, 2026
b1f8bfe
Call webhook when intent is changed
krestenlaust Apr 4, 2026
d2c8583
fixup! Call webhook when intent is changed
krestenlaust Apr 5, 2026
fc8019d
fixup! Call webhook when intent is changed
krestenlaust Apr 5, 2026
22e683c
Remove 'fulfilled_at'
krestenlaust Apr 5, 2026
8504b43
Docstring
krestenlaust Apr 5, 2026
add3a20
Update intent to expired on check
krestenlaust Apr 5, 2026
ad31526
Move intent finalize logic into method
krestenlaust Apr 5, 2026
e996cd7
Try finalize intents on new payment received
krestenlaust Apr 5, 2026
2137eab
fixup! Move intent finalize logic into method
krestenlaust Apr 5, 2026
540002a
fixup! Add helper methods to Order class
krestenlaust Apr 5, 2026
197a45a
Still acknowledge /accept when member has no funds
krestenlaust Apr 5, 2026
03a0df2
Signal on balance change instead of payment
krestenlaust Apr 5, 2026
694a445
Merge branch 'next' into feat/payment-intent
krestenlaust May 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion openapi/dredd_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
}

skipped_endpoints = [
"GET (400) /api/member/payment/qr?username=kresten" # Skipped: test can't be implemented properly in OpenAPI
"GET (400) /api/member/payment/qr?username=kresten", # Skipped: test can't be implemented properly in OpenAPI
"GET (404) /api/sale/intent/a3f2c1d4-88b0-4e2a-9c3e-1f5b6d7e8a9b",
"GET (200) /api/sale/intent/a3f2c1d4-88b0-4e2a-9c3e-1f5b6d7e8a9b",
]

@hooks.before_each
Expand Down
244 changes: 243 additions & 1 deletion openapi/stregsystem.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ tags:
- name: Products
description: Related to the products.
- name: Sale
description: Related to performing a sale.
description: Related to performing a sale directly.
- name: Sale Intent
description: |-
Consumer-initiated purchase flow. A consumer creates a sale intent and receives
a URL where the member can log in and confirm the purchase themselves.
Once confirmed (or rejected/expired), the consumer is notified via a webhook callback.
- name: Signup
description: Related to registration of new members.
paths:
Expand Down Expand Up @@ -173,6 +178,47 @@ paths:
$ref: '#/components/responses/SaleSuccess'
'400':
$ref: '#/components/responses/Member_RoomIdParameter_BadResponse'
/api/sale/intent:
post:
tags:
- Sale Intent
summary: Create a sale intent
description: |-
Creates a sale intent on behalf of a consumer. No money is transferred at
this point. The response contains a `confirmation_url` that should be presented to
the member (e.g. opened in a popup window, similar to PayPal's checkout flow).
The member logs in at that URL and either confirms or rejects the purchase.

Once the intent is resolved (confirmed, rejected, or expired), Stregsystem will
POST a webhook callback to the `webhook_url` supplied in the request body.
operationId: api_sale_intent
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/sale_intent_input'
responses:
'201':
$ref: '#/components/responses/SaleIntentCreated'
'400':
$ref: '#/components/responses/RoomIdParameter_BadResponse'
/api/sale/intent/{intent_id}:
get:
tags:
- Sale Intent
summary: Get sale intent status
description: |-
Returns the current status of a sale intent. Consumers can poll this endpoint
as an alternative (or complement) to waiting for the webhook callback.
operationId: api_sale_intent_get
parameters:
- $ref: '#/components/parameters/intent_id_param'
responses:
'200':
$ref: '#/components/responses/SaleIntentStatus'
'404':
$ref: '#/components/responses/SaleIntentNotFound'
/api/signup/status:
get:
tags:
Expand Down Expand Up @@ -276,6 +322,14 @@ components:
examples:
normalBalance:
value: 20000
intent_id_param:
name: intent_id
in: path
description: Unique identifier of the sale intent.
required: true
schema:
$ref: '#/components/schemas/intent_id'
example: "a3f2c1d4-88b0-4e2a-9c3e-1f5b6d7e8a9b"
schemas:
memberNotFoundMessage:
type: string
Expand Down Expand Up @@ -423,6 +477,9 @@ components:
buystring:
type: string
example: "kresten beer:3"
productstring:
type: string
example: "beer:3"
product_category_name:
type: string
example: "Alcohol"
Expand Down Expand Up @@ -567,6 +624,146 @@ components:
api_version:
type: string
example: 1.1
intent_id:
type: string
format: uuid
description: Universally unique identifier for a sale intent.
example: "a3f2c1d4-88b0-4e2a-9c3e-1f5b6d7e8a9b"
intent_secret:
type: string
format: uuid
description: Universally unique identifier for a sale intent. Provided as common secret for hashing.
example: "b9f2c1d4-88b0-4e2a-3d3e-1f5b6d7e8a9b"
intent_status:
type: string
description: |-
Lifecycle state of the sale intent.
- `initiated` – Created, awaiting member confirmation.
- `pending` – Member approved sale, waiting to be finalized.
- `aborted` – Member declined at the confirmation page.
- `finalized` – Sale has been executed.
- `cancelled` – Consumer cancelled via DELETE before confirmation, or payment provider cancelled.
- `expired` – Member did not act within the TTL window.
enum:
- initiated
- pending
- aborted
- finalized
- cancelled
- expired
example: initiated
sale_intent_input:
type: object
required:
- productstring
- room_id
properties:
productstring:
$ref: '#/components/schemas/productstring'
room_id:
$ref: '#/components/schemas/room_id'
webhook_url:
type: string
format: uri
description: |-
HTTPS URL that Stregsystem will POST to when the intent is resolved.
The payload will be a `sale_intent_webhook_payload` object.
example: "https://consumer.example.com/webhooks/stregsystem"
return_url:
type: string
format: uri
description: |-
The return page for when the payment is succeeded.
example: "https://shop.example.com/order/12345"
max_expires_in_seconds:
type: integer
description: |-
How many seconds the intent at most can be valid at before the intent
automatically transitions to `expired`. Defaults to 300 (5 minutes)
if omitted.
default: 300
minimum: 30
maximum: 3600
example: 300
example:
productstring: "beer:3"
room_id: 10
webhook_url: "https://webshop.example.com/webhooks/stregsystem"
return_url: "https://webshop.example.com/order/12345"
max_expires_in_seconds: 300
sale_intent_created_response:
type: object
properties:
id:
$ref: '#/components/schemas/intent_id'
secret:
$ref: '#/components/schemas/intent_secret'
status:
$ref: '#/components/schemas/intent_status'
confirmation_url:
type: string
format: uri
description: |-
URL to present to the member. Opening this URL (e.g. in a popup window)
lets the member log in and confirm or abort the purchase. The URL is
single-use and expires after `expires_in_seconds` seconds.
example: "https://stregsystem.fklub.dk/sale/confirm/a3f2c1d4-88b0-4e2a-9c3e-1f5b6d7e8a9b"
expires_at:
type: string
format: date-time
description: ISO 8601 timestamp at which this intent will expire if unconfirmed.
example: "2024-05-12T18:31:09.508Z"
productstring:
$ref: '#/components/schemas/productstring'
room_id:
$ref: '#/components/schemas/room_id'
sale_intent_status_response:
type: object
properties:
status:
$ref: '#/components/schemas/intent_status'
expires_at:
type: string
format: date-time
nullable: true
example: "2024-05-12T18:31:09.508Z"
updated_at:
type: string
format: date-time
nullable: true
example: "2024-05-12T18:31:09.508Z"
productstring:
$ref: '#/components/schemas/productstring'
details:
description: Populated only when `status` is `finalized`. Contains the same payload as a direct `/api/sale` success response.
nullable: true
allOf:
- $ref: '#/components/schemas/sale_values_result_example'
example:
intent_id: "a3f2c1d4-88b0-4e2a-9c3e-1f5b6d7e8a9b"
status: "finalized"
productstring: "beer:3"
room_id: 10
resolved_at: "2024-05-12T18:29:44.123Z"
sale_result:
order:
room: 10
member: 321
created_on: "2024-05-12T18:29:44.123Z"
items: [123, 123, 123]
cost: 1800
member_balance: 178.00
member_has_low_balance: false
promille: 0.2
is_ballmer_peaking: false
bp_minutes: null
bp_seconds: null
caffeine: 0
cups: 0
product_contains_caffeine: false
is_coffee_master: false
give_multibuy_hint: false
sale_hints: ""
responses:
MemberFound:
description: Member found.
Expand Down Expand Up @@ -758,6 +955,51 @@ components:
$ref: '#/components/examples/InvalidRoomIdExample'
missingRoomId:
$ref: '#/components/examples/MissingRoomIdExample'
SaleIntentCreated:
description: Sale intent successfully created. Present the `confirmation_url` to the member.
content:
application/json:
schema:
$ref: '#/components/schemas/sale_intent_created_response'
SaleIntentStatus:
description: Current state of the sale intent.
content:
application/json:
schema:
$ref: '#/components/schemas/sale_intent_status_response'
SaleIntentCancelled:
description: Sale intent successfully cancelled. The webhook will be called.
content:
application/json:
schema:
type: object
properties:
intent_id:
$ref: '#/components/schemas/intent_id'
status:
$ref: '#/components/schemas/intent_status'
SaleIntentNotFound:
description: No sale intent exists with the given `intent_id`.
content:
application/json:
schema:
type: object
properties:
detail:
type: string
example: "Sale intent not found"
SaleIntentAlreadyResolved:
description: The sale intent has already been confirmed, rejected, or expired and cannot be cancelled.
content:
application/json:
schema:
type: object
properties:
detail:
type: string
example: "Sale intent has already been resolved"
status:
$ref: '#/components/schemas/intent_status'
Signup_BadResponse:
description: Username is taken, missing parameter, or invalid parameter.
content:
Expand Down
5 changes: 3 additions & 2 deletions stregsystem/apps.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
from django.apps import AppConfig

from django.db.models.signals import post_save
from stregsystem.signals import after_member_save, after_pending_signup_save
from stregsystem.signals import after_member_save, after_pending_signup_save, after_intent_save


class StregConfig(AppConfig):
name = 'stregsystem'

def ready(self):
from stregsystem.models import Member, PendingSignup
from stregsystem.models import Member, PendingSignup, Intent

post_save.connect(after_member_save, sender=Member)
post_save.connect(after_pending_signup_save, sender=PendingSignup)
post_save.connect(after_intent_save, sender=Intent)
61 changes: 61 additions & 0 deletions stregsystem/migrations/0025_intent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Generated by Django 4.1.13 on 2026-03-21 22:46

from django.db import migrations, models
import django.db.models.deletion
import uuid


class Migration(migrations.Migration):

dependencies = [
("stregsystem", "0024_alter_productnote_products"),
]

operations = [
migrations.CreateModel(
name="Intent",
fields=[
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("secret", models.UUIDField(default=uuid.uuid4, editable=False)),
("webhook_url", models.URLField(blank=True, null=True)),
("expires_at", models.DateTimeField()),
("buystring", models.TextField()),
(
"status",
models.CharField(
choices=[
("P", "Pending"),
("C", "Confirmed"),
("A", "Aborted"),
("F", "Finalized"),
("R", "Removed"),
("E", "Expired"),
],
default="P",
max_length=1,
),
),
(
"room",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="stregsystem.room",
),
),
],
options={
"abstract": False,
},
),
]
Loading
Loading