-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Python payments state machine example
- Loading branch information
Showing
7 changed files
with
139 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
Empty file.
39 changes: 39 additions & 0 deletions
39
python/patterns-use-cases/src/statemachinepayments/accounts/accounts.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
from restate import ObjectContext, VirtualObject | ||
from restate.exceptions import TerminalError | ||
import random | ||
|
||
from src.statemachinepayments.types import Result | ||
|
||
# A simple virtual object, to track accounts. | ||
# This is for simplicity to make this example work self-contained. | ||
# This should be a database in a real scenario | ||
account = VirtualObject("account") | ||
|
||
# The key under which we store the balance | ||
BALANCE = "balance" | ||
|
||
|
||
@account.handler() | ||
async def deposit(ctx: ObjectContext, amount_cents: int): | ||
if amount_cents <= 0: | ||
raise TerminalError("Amount must be greater than 0") | ||
|
||
balance_cents = await ctx.get(BALANCE) or initialize_random_amount() | ||
ctx.set(BALANCE, balance_cents + amount_cents) | ||
|
||
|
||
@account.handler() | ||
async def withdraw(ctx: ObjectContext, amount_cents: int) -> Result: | ||
if amount_cents <= 0: | ||
raise TerminalError("Amount must be greater than 0") | ||
|
||
balance_cents = await ctx.get(BALANCE) or initialize_random_amount() | ||
if balance_cents < amount_cents: | ||
return Result(success=False, message=f"Insufficient funds: {balance_cents} cents") | ||
|
||
ctx.set(BALANCE, balance_cents - amount_cents) | ||
return Result(success=True, message="Withdrawal successful") | ||
|
||
|
||
def initialize_random_amount() -> int: | ||
return random.randint(100_000, 200_000) |
88 changes: 88 additions & 0 deletions
88
python/patterns-use-cases/src/statemachinepayments/payment_processor.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
import restate | ||
from pydantic import BaseModel | ||
from restate import VirtualObject, ObjectContext | ||
from datetime import timedelta | ||
from accounts.accounts import account | ||
import accounts.accounts as account_service | ||
from src.statemachinepayments.types import Result | ||
|
||
# A service that processes the payment requests. | ||
# This is implemented as a virtual object to ensure that only one concurrent request can happen | ||
# per payment-id. Requests are queued and processed sequentially per id. | ||
# Methods can be called multiple times with the same payment-id, but payment will be executed | ||
# only once. If a 'cancelPayment' is called for an id, the payment will either be undone, or | ||
# blocked from being made in the future, depending on whether the cancel call comes before or after | ||
# the 'makePayment' call. | ||
payment_processor = VirtualObject("PaymentProcessor") | ||
|
||
# The key under which we store the status. | ||
STATUS = "status" | ||
|
||
# The key under which we store the original payment request. | ||
PAYMENT = "payment" | ||
|
||
EXPIRY_TIMEOUT = timedelta(days=1) | ||
|
||
|
||
class Payment(BaseModel): | ||
account_id: str | ||
amount_cents: int | ||
|
||
|
||
class PaymentStatus: | ||
NEW = "NEW" | ||
COMPLETED_SUCCESSFULLY = "COMPLETED_SUCCESSFULLY" | ||
CANCELED = "CANCELED" | ||
|
||
|
||
@payment_processor.handler("makePayment") | ||
async def make_payment(ctx: ObjectContext, payment: Payment) -> Result: | ||
payment_id = ctx.key() | ||
status = await ctx.get(STATUS) or PaymentStatus.NEW | ||
|
||
if status == PaymentStatus.CANCELED: | ||
return Result(success=False, message="Payment already cancelled") | ||
if status == PaymentStatus.COMPLETED_SUCCESSFULLY: | ||
return Result(success=False, message="Payment already completed in prior call") | ||
|
||
# Charge the target account | ||
payment_result = await ctx.object_call(account_service.withdraw, key=payment.account_id, arg=payment.amount_cents) | ||
|
||
# Remember only on success, so that on failure (when we didn't charge) the external | ||
# caller may retry this (with the same payment-id), for the sake of this example | ||
if payment_result.success: | ||
ctx.set(STATUS, PaymentStatus.COMPLETED_SUCCESSFULLY) | ||
ctx.set(PAYMENT, payment.model_dump()) | ||
ctx.object_send(expire, payment_id, send_delay=EXPIRY_TIMEOUT, arg=None) | ||
|
||
return payment_result | ||
|
||
|
||
@payment_processor.handler("cancelPayment") | ||
async def cancel_payment(ctx: ObjectContext): | ||
status = await ctx.get(STATUS) or PaymentStatus.NEW | ||
|
||
if status == PaymentStatus.NEW: | ||
# not seen this payment-id before, mark as canceled, in case the cancellation | ||
# overtook the actual payment request (on the external caller's side) | ||
ctx.set(STATUS, PaymentStatus.CANCELED) | ||
ctx.object_send(expire, ctx.key(), send_delay=EXPIRY_TIMEOUT, arg=None) | ||
|
||
elif status == PaymentStatus.CANCELED: | ||
pass | ||
|
||
elif status == PaymentStatus.COMPLETED_SUCCESSFULLY: | ||
# remember this as cancelled | ||
ctx.set(STATUS, PaymentStatus.CANCELED) | ||
|
||
# undo the payment | ||
payment = Payment(**await ctx.get(PAYMENT)) | ||
ctx.object_send(account_service.deposit, key=payment.account_id, arg=payment.amount_cents) | ||
|
||
|
||
@payment_processor.handler() | ||
async def expire(ctx: ObjectContext): | ||
ctx.clear_all() | ||
|
||
|
||
app = restate.app([payment_processor, account]) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
from pydantic import BaseModel | ||
|
||
|
||
class Result(BaseModel): | ||
success: bool | ||
message: str |