Charge Stored Card
The ChargeStoredCard effect charges a patient’s stored payment card through the payment processor configured for your Canvas instance. The charge is processed server-side against the tokenized card, so no card data crosses the plugin boundary.
You reference the card by an identifier the configured processor understands, never the underlying processor token:
- The built-in Stripe processor takes the Canvas PaymentCard id.
- A custom payment processor takes whichever reference that processor manages.
Imports #
from canvas_sdk.effects.payment import ChargeStoredCard
Charging a stored card #
Import the ChargeStoredCard class, create an instance of it, and return its .apply() method from compute.
| Attribute | Type | Description | |
|---|---|---|---|
| patient_id | required | String | The Canvas Patient id to charge. |
| payment_card_id | required | String | A reference to the stored card that the configured processor resolves: the Canvas PaymentCard id for the built-in Stripe processor, or a custom processor’s own reference. |
| amount | required | Decimal | The amount to charge, in dollars, with up to two decimal places (for example, Decimal("49.99")). Must be greater than 0. |
| idempotency_key | required | UUID or String | A key that makes the charge safe to retry. Reusing the same key for a retry guarantees the patient is not charged twice. See Idempotency. |
| claim_id | optional* | UUID or String | A Claim id to post the payment against. When omitted, the payment is allocated across the patient’s outstanding balance. See Payment allocation. |
| copay | optional | Boolean | Whether the charge is a copay. When true, the amount is posted to the claim’s copay line item; requires claim_id. Defaults to false. See Payment allocation. |
| description | optional | String | A free-text description to record with the payment. It has no length limit, and is recorded only on a charge that names a claim_id. |
* claim_id is required when copay is true.
Example:
from decimal import Decimal
from uuid import uuid5, NAMESPACE_URL
from canvas_sdk.effects import Effect
from canvas_sdk.effects.payment import ChargeStoredCard
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
from canvas_sdk.v1.data import Patient
class ChargeBalanceOnBooking(BaseHandler):
RESPONDS_TO = [EventType.Name(EventType.APPOINTMENT_CREATED)]
def compute(self) -> list[Effect]:
# APPOINTMENT_CREATED targets the appointment; the patient is in the context.
appointment_id = self.event.target.id
patient = Patient.objects.get(id=self.event.context["patient"]["id"])
card = patient.payment_cards.filter(is_default=True).first()
if card is None:
return []
charge = ChargeStoredCard(
patient_id=str(patient.id),
payment_card_id=str(card.id),
amount=Decimal("25.00"),
idempotency_key=uuid5(NAMESPACE_URL, f"appointment-{appointment_id}-balance"),
)
return [charge.apply()]
This charge names no claim_id, so it pays down the patient’s outstanding balance and is rejected unless that balance covers the amount. To take a copay instead, supply the claim_id it belongs to and set copay=True. See Payment allocation.
Validation #
.apply() raises a ValidationError when:
- The
patient_iddoes not match a patient. - A supplied
claim_iddoes not match a claim. copayis set without aclaim_id.
The card itself is not checked here. payment_card_id is resolved and validated server-side when the charge is processed, so a reference the configured processor cannot resolve arrives as an error on the response event rather than raising from .apply(). See Error codes for the code each processor reports.
Card ownership #
Under the built-in Stripe processor, Canvas looks the card up scoped to patient_id, so a card belonging to a different patient, or one that has been removed, is rejected as card_not_found before the card is charged.
A custom payment processor keeps its own cards, which Canvas cannot see, so it passes payment_card_id through without checking it. On a custom processor, confirming that a card reference belongs to the patient being charged is the processor’s responsibility.
Idempotency #
Every charge requires an idempotency_key. Pass either a UUID object or its string form, dashed or not; a string that does not parse as a UUID is rejected.
Reusing the same key on a retry guarantees the patient is not charged twice, so the key must be stable across retries of the same logical charge. Generate it deterministically from a stable identifier — for example uuid5(namespace, f"appointment-{id}-charge") — or persist a uuid4 before emitting the effect. Do not generate a fresh key on each attempt.
Payment allocation #
The payment is always applied to the patient’s account:
- Outstanding balance (default). When no
claim_idis given, the payment is allocated across the patient’s outstanding balance. This is the only case where the amount is measured against a balance: the charge is rejected before the card is touched if the patient has no outstanding balance, and again if the amount exceeds it, so every charged dollar lands on a claim. - Against a specific claim. When
claim_idis given andcopayis leftfalse(the default), the payment pays down that claim’s non-copay line-item balances. The charge is rejected if the claim has nothing to charge against, but the amount is not capped at the claim’s balance, so an amount above it leaves the claim in credit. - As a copay on a claim. When
claim_idis given withcopayset totrue, the amount is recorded as a copay on that claim, posted to its copay line item. No balance is checked at all, so use this to collect a copay or prepayment against a claim that carries nothing to charge.
Reconciling the charge #
Once the charge has been processed, Canvas emits a REVENUE__STORED_CARD__CHARGE_RESPONSE event carrying the outcome. It fires whether or not the card was charged, and nothing a handler returns changes the charge, so handle it to reconcile rather than to intervene.
The event targets the patient that was charged: self.event.target.id is the patient id, and self.event.target.instance is the Patient. Its actor is the one that emitted the originating effect.
Correlate each response with the charge that produced it using the idempotency_key you supplied on the request. It is the only field that ties the two together, since nothing in the response identifies the handler that asked for the charge.
This handler records the result of each charge, reading payment_intent_id when it succeeds and the error object when it does not:
from canvas_sdk.effects import Effect
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
from logger import log
class ReconcileStoredCardCharge(BaseHandler):
RESPONDS_TO = [EventType.Name(EventType.REVENUE__STORED_CARD__CHARGE_RESPONSE)]
def compute(self) -> list[Effect]:
context = self.event.context
idempotency_key = context["idempotency_key"]
if context["success"]:
payment_intent_id = context["payment_intent_id"]
log.info(f"Charge {idempotency_key} succeeded: {payment_intent_id}")
else:
error = context["error"]
log.error(f"Charge {idempotency_key} failed [{error['code']}]: {error['message']}")
return []
Response context #
self.event.context arrives already parsed as a dictionary, so read the fields from it directly. It carries no card data or PHI. Apart from success, which is a boolean, and error, which is an object, every value is a string or null, including the amount and the ids that were UUIDs on the request, so cast amount before doing arithmetic on it.
| Field | Type | Description |
|---|---|---|
| success | Boolean | Whether the charge succeeded. |
| payment_intent_id | String or null | The processor’s payment identifier on success — the Stripe PaymentIntent id, or a custom processor’s transaction id. null on failure. |
| error | Object or null | null on success. On failure, error is an object with code and message fields. See Error codes. |
| idempotency_key | String | Echoed from the request. Correlate the response with the originating charge using this value. |
| patient_id | String | Echoed from the request: the Patient that was charged. |
| payment_card_id | String | Echoed from the request: the stored card that was charged, a PaymentCard id under the built-in processor. |
| claim_id | String or null | Echoed from the request: the Claim the payment was posted against, or null when none was supplied. |
| amount | String | Echoed from the request: the dollar amount that was submitted. |
Error codes #
On failure, error.code names the condition that stopped the charge.
error.code | When it occurs |
|---|---|
patient_not_found | The patient_id did not match a patient. |
card_not_found | The stored card was not found. For the built-in Stripe processor, this also covers a card that exists but is not owned by the patient; a custom processor may raise a different code for that case — check the processor’s own documentation. |
claim_not_found | The claim_id did not match a claim owned by the patient. |
no_outstanding_balance | A charge with no claim_id was attempted, but the patient has no outstanding balance. The balance is checked before the card is charged. |
amount_exceeds_balance | A charge with no claim_id exceeds the patient’s outstanding balance. The amount is validated against the balance before the card is charged. |
nothing_to_charge | A per-claim charge with copay set to false was attempted, but the claim has no chargeable, non-copay balance. Set copay to true to record a copay or prepayment instead. |
charge_failed | An unexpected failure occurred while processing the charge. Retry using the same idempotency_key; the retry will not double-charge the patient. |
The payment processor’s own decline codes also pass through in error.code — for example, card_declined from Stripe — and can arrive at charge time. Handle an unrecognized code gracefully rather than matching against a fixed set, since the processor can surface codes this list does not name.