Skip to content

Commit bc3201c

Browse files
matteiusclaude
andcommitted
feat: add pluggable payment system with Stripe provider
Three-layer payment architecture: - PaymentProvider ABC (payments/base.py): defines provider interface with create/confirm/webhook/refund lifecycle methods, PaymentFlow (INLINE/REDIRECT), PaymentStatus enum, and PaymentResult dataclass - Provider Registry (payments/registry.py): register_provider() for self-registration in AppConfig.ready(), get_provider(), get_available_providers() - Stripe Provider (payments/stripe_provider.py): INLINE flow using PaymentIntents with automatic_payment_methods Models: - PaymentRecord: tracks payment lifecycle per submission (provider, transaction_id, amount, currency, status, idempotency_key) - 7 payment config fields on FormDefinition (enabled, provider, amount_type, fixed_amount, amount_field, currency, description_template) - pending_payment status on FormSubmission View flow: form validates -> save as pending_payment -> initiate_payment -> provider creates payment -> inline page or external redirect -> confirmation -> _finalize_submission() triggers workflow 5 URL endpoints: initiate, confirm, return (redirect flow), cancel, webhook Also includes: - Form builder UI: payment section with provider dropdown, amount type, currency - Admin: PaymentRecordAdmin (read-only) + PaymentRecordInline on submissions, Payment fieldset on FormDefinitionAdmin - sync_api: payment fields in export/import serialization - clone_forms: payment fields included in form cloning - JS: payment-stripe.js for Stripe Elements integration - Templates: payment_collect.html, payment_error.html - Migration 0085 - 28 new tests (model, registry, views, provider, data structures) Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent 9c7e4c9 commit bc3201c

File tree

21 files changed

+1994
-2
lines changed

21 files changed

+1994
-2
lines changed

django_forms_workflows/admin.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
ManagedFile,
3838
NotificationLog,
3939
NotificationRule,
40+
PaymentRecord,
4041
PostSubmissionAction,
4142
PrefillSource,
4243
SharedOptionList,
@@ -702,6 +703,20 @@ class FormDefinitionAdmin(nested_admin.NestedModelAdmin):
702703
),
703704
},
704705
),
706+
(
707+
"Payment",
708+
{
709+
"classes": ("collapse",),
710+
"fields": (
711+
"payment_enabled",
712+
"payment_provider",
713+
("payment_amount_type", "payment_fixed_amount"),
714+
"payment_amount_field",
715+
"payment_currency",
716+
"payment_description_template",
717+
),
718+
},
719+
),
705720
(
706721
"Metadata",
707722
{
@@ -832,6 +847,13 @@ def clone_forms(self, request, queryset):
832847
allow_withdrawal=form.allow_withdrawal,
833848
allow_resubmit=form.allow_resubmit,
834849
allow_batch_import=form.allow_batch_import,
850+
payment_enabled=form.payment_enabled,
851+
payment_provider=form.payment_provider,
852+
payment_amount_type=form.payment_amount_type,
853+
payment_fixed_amount=form.payment_fixed_amount,
854+
payment_amount_field=form.payment_amount_field,
855+
payment_currency=form.payment_currency,
856+
payment_description_template=form.payment_description_template,
835857
created_by=request.user,
836858
)
837859

@@ -2053,6 +2075,26 @@ def has_change_permission(self, request, obj=None):
20532075
return False
20542076

20552077

2078+
class PaymentRecordInline(admin.TabularInline):
2079+
model = PaymentRecord
2080+
extra = 0
2081+
readonly_fields = (
2082+
"provider_name",
2083+
"transaction_id",
2084+
"amount",
2085+
"currency",
2086+
"status",
2087+
"error_message",
2088+
"created_at",
2089+
"completed_at",
2090+
"idempotency_key",
2091+
)
2092+
can_delete = False
2093+
2094+
def has_add_permission(self, request, obj=None):
2095+
return False
2096+
2097+
20562098
@admin.register(FormSubmission)
20572099
class FormSubmissionAdmin(admin.ModelAdmin):
20582100
list_display = (
@@ -2074,7 +2116,7 @@ class FormSubmissionAdmin(admin.ModelAdmin):
20742116
)
20752117
raw_id_fields = ("submitter",)
20762118
readonly_fields = ("created_at", "submitted_at", "completed_at")
2077-
inlines = [ChangeHistoryInline]
2119+
inlines = [PaymentRecordInline, ChangeHistoryInline]
20782120

20792121

20802122
@admin.register(ApprovalTask)
@@ -2262,6 +2304,41 @@ def item_count(self, obj):
22622304
item_count.short_description = "Options"
22632305

22642306

2307+
@admin.register(PaymentRecord)
2308+
class PaymentRecordAdmin(admin.ModelAdmin):
2309+
list_display = (
2310+
"transaction_id",
2311+
"submission",
2312+
"provider_name",
2313+
"amount",
2314+
"currency",
2315+
"status",
2316+
"created_at",
2317+
"completed_at",
2318+
)
2319+
list_filter = ("status", "provider_name", "currency")
2320+
search_fields = ("transaction_id", "submission__id", "idempotency_key")
2321+
readonly_fields = (
2322+
"submission",
2323+
"form_definition",
2324+
"provider_name",
2325+
"transaction_id",
2326+
"amount",
2327+
"currency",
2328+
"description",
2329+
"status",
2330+
"error_message",
2331+
"provider_data",
2332+
"created_at",
2333+
"updated_at",
2334+
"completed_at",
2335+
"idempotency_key",
2336+
)
2337+
2338+
def has_add_permission(self, request):
2339+
return False
2340+
2341+
22652342
# --- File Upload Configuration Admin ---
22662343

22672344

django_forms_workflows/apps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,9 @@ def ready(self):
2323
from .callback_registry import load_from_settings
2424

2525
load_from_settings()
26+
27+
# Auto-register bundled payment providers
28+
from .payments import register_provider
29+
from .payments.stripe_provider import StripePaymentProvider
30+
31+
register_provider("stripe", StripePaymentProvider)

django_forms_workflows/form_builder_views.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,11 +254,17 @@ def form_builder_view(request, form_id=None):
254254
"name"
255255
)
256256

257+
# Get available payment providers
258+
from .payments import get_provider_choices
259+
260+
payment_provider_choices = get_provider_choices()
261+
257262
context = {
258263
"form_definition": form_definition,
259264
"prefill_sources": prefill_sources,
260265
"shared_option_lists": shared_option_lists,
261266
"field_types": field_types_json,
267+
"payment_provider_choices_json": json.dumps(payment_provider_choices),
262268
"is_new": form_id is None,
263269
}
264270

@@ -328,6 +334,15 @@ def form_builder_load(request, form_id):
328334
else None,
329335
"max_submissions": form_definition.max_submissions,
330336
"one_per_user": form_definition.one_per_user,
337+
"payment_enabled": form_definition.payment_enabled,
338+
"payment_provider": form_definition.payment_provider,
339+
"payment_amount_type": form_definition.payment_amount_type,
340+
"payment_fixed_amount": str(form_definition.payment_fixed_amount)
341+
if form_definition.payment_fixed_amount
342+
else None,
343+
"payment_amount_field": form_definition.payment_amount_field,
344+
"payment_currency": form_definition.payment_currency,
345+
"payment_description_template": form_definition.payment_description_template,
331346
"enable_captcha": form_definition.enable_captcha,
332347
"enable_multi_step": form_definition.enable_multi_step,
333348
"form_steps": form_definition.form_steps or [],
@@ -367,6 +382,13 @@ def form_builder_save(request):
367382
close_date = data.get("close_date") or None
368383
max_submissions = data.get("max_submissions") or None
369384
one_per_user = data.get("one_per_user", False)
385+
payment_enabled = data.get("payment_enabled", False)
386+
payment_provider = data.get("payment_provider", "")
387+
payment_amount_type = data.get("payment_amount_type", "fixed")
388+
payment_fixed_amount = data.get("payment_fixed_amount") or None
389+
payment_amount_field = data.get("payment_amount_field", "")
390+
payment_currency = data.get("payment_currency", "usd")
391+
payment_description_template = data.get("payment_description_template", "")
370392
enable_captcha = data.get("enable_captcha", False)
371393
enable_multi_step = data.get("enable_multi_step", False)
372394
form_steps = data.get("form_steps", [])
@@ -403,6 +425,15 @@ def form_builder_save(request):
403425
form_definition.close_date = close_date
404426
form_definition.max_submissions = max_submissions
405427
form_definition.one_per_user = one_per_user
428+
form_definition.payment_enabled = payment_enabled
429+
form_definition.payment_provider = payment_provider
430+
form_definition.payment_amount_type = payment_amount_type
431+
form_definition.payment_fixed_amount = payment_fixed_amount
432+
form_definition.payment_amount_field = payment_amount_field
433+
form_definition.payment_currency = payment_currency
434+
form_definition.payment_description_template = (
435+
payment_description_template
436+
)
406437
form_definition.enable_captcha = enable_captcha
407438
form_definition.enable_multi_step = enable_multi_step
408439
form_definition.form_steps = form_steps
@@ -426,6 +457,13 @@ def form_builder_save(request):
426457
close_date=close_date,
427458
max_submissions=max_submissions,
428459
one_per_user=one_per_user,
460+
payment_enabled=payment_enabled,
461+
payment_provider=payment_provider,
462+
payment_amount_type=payment_amount_type,
463+
payment_fixed_amount=payment_fixed_amount,
464+
payment_amount_field=payment_amount_field,
465+
payment_currency=payment_currency,
466+
payment_description_template=payment_description_template,
429467
enable_captcha=enable_captcha,
430468
enable_multi_step=enable_multi_step,
431469
form_steps=form_steps,
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# Generated by Django 5.2.7 on 2026-04-02 00:05
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("django_forms_workflows", "0084_add_shared_option_list"),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name="formdefinition",
16+
name="payment_amount_field",
17+
field=models.CharField(
18+
blank=True,
19+
help_text="field_name of a currency/decimal field for dynamic amounts.",
20+
max_length=100,
21+
),
22+
),
23+
migrations.AddField(
24+
model_name="formdefinition",
25+
name="payment_amount_type",
26+
field=models.CharField(
27+
blank=True,
28+
choices=[("fixed", "Fixed Amount"), ("field", "From Form Field")],
29+
default="fixed",
30+
max_length=20,
31+
),
32+
),
33+
migrations.AddField(
34+
model_name="formdefinition",
35+
name="payment_currency",
36+
field=models.CharField(
37+
blank=True,
38+
default="usd",
39+
help_text="ISO 4217 currency code (e.g., 'usd', 'cad').",
40+
max_length=3,
41+
),
42+
),
43+
migrations.AddField(
44+
model_name="formdefinition",
45+
name="payment_description_template",
46+
field=models.CharField(
47+
blank=True,
48+
help_text="Charge description. Supports {field_name} tokens.",
49+
max_length=500,
50+
),
51+
),
52+
migrations.AddField(
53+
model_name="formdefinition",
54+
name="payment_enabled",
55+
field=models.BooleanField(
56+
default=False, help_text="Require payment for form submission."
57+
),
58+
),
59+
migrations.AddField(
60+
model_name="formdefinition",
61+
name="payment_fixed_amount",
62+
field=models.DecimalField(
63+
blank=True,
64+
decimal_places=2,
65+
help_text="Fixed payment amount (when amount type is 'fixed').",
66+
max_digits=10,
67+
null=True,
68+
),
69+
),
70+
migrations.AddField(
71+
model_name="formdefinition",
72+
name="payment_provider",
73+
field=models.CharField(
74+
blank=True,
75+
help_text="Registered payment provider key (e.g., 'stripe').",
76+
max_length=50,
77+
),
78+
),
79+
migrations.AlterField(
80+
model_name="formsubmission",
81+
name="status",
82+
field=models.CharField(
83+
choices=[
84+
("draft", "Draft"),
85+
("pending_payment", "Pending Payment"),
86+
("submitted", "Submitted"),
87+
("pending_approval", "Pending Approval"),
88+
("approved", "Approved"),
89+
("rejected", "Rejected"),
90+
("withdrawn", "Withdrawn"),
91+
],
92+
default="draft",
93+
max_length=20,
94+
),
95+
),
96+
migrations.CreateModel(
97+
name="PaymentRecord",
98+
fields=[
99+
(
100+
"id",
101+
models.BigAutoField(
102+
auto_created=True,
103+
primary_key=True,
104+
serialize=False,
105+
verbose_name="ID",
106+
),
107+
),
108+
("provider_name", models.CharField(max_length=50)),
109+
(
110+
"transaction_id",
111+
models.CharField(blank=True, db_index=True, max_length=255),
112+
),
113+
("amount", models.DecimalField(decimal_places=2, max_digits=10)),
114+
("currency", models.CharField(default="usd", max_length=3)),
115+
("description", models.CharField(blank=True, max_length=500)),
116+
(
117+
"status",
118+
models.CharField(
119+
choices=[
120+
("pending", "Pending"),
121+
("requires_action", "Requires Action"),
122+
("processing", "Processing"),
123+
("completed", "Completed"),
124+
("failed", "Failed"),
125+
("cancelled", "Cancelled"),
126+
("refunded", "Refunded"),
127+
],
128+
default="pending",
129+
max_length=20,
130+
),
131+
),
132+
("error_message", models.TextField(blank=True)),
133+
("provider_data", models.JSONField(blank=True, null=True)),
134+
("created_at", models.DateTimeField(auto_now_add=True)),
135+
("updated_at", models.DateTimeField(auto_now=True)),
136+
("completed_at", models.DateTimeField(blank=True, null=True)),
137+
("idempotency_key", models.CharField(max_length=255, unique=True)),
138+
(
139+
"form_definition",
140+
models.ForeignKey(
141+
on_delete=django.db.models.deletion.PROTECT,
142+
related_name="payment_records",
143+
to="django_forms_workflows.formdefinition",
144+
),
145+
),
146+
(
147+
"submission",
148+
models.ForeignKey(
149+
on_delete=django.db.models.deletion.CASCADE,
150+
related_name="payment_records",
151+
to="django_forms_workflows.formsubmission",
152+
),
153+
),
154+
],
155+
options={
156+
"verbose_name": "Payment Record",
157+
"verbose_name_plural": "Payment Records",
158+
"ordering": ["-created_at"],
159+
"indexes": [
160+
models.Index(
161+
fields=["submission", "status"],
162+
name="django_form_submiss_d8e9e5_idx",
163+
),
164+
models.Index(
165+
fields=["transaction_id"], name="django_form_transac_44dbea_idx"
166+
),
167+
],
168+
},
169+
),
170+
]

0 commit comments

Comments
 (0)